Skip to content

Commit 2ef97b5

Browse files
feat: add token refresh and rotation conformance scenarios
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>
1 parent 5d26671 commit 2ef97b5

7 files changed

Lines changed: 610 additions & 46 deletions

File tree

examples/clients/typescript/everything-client.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,66 @@ export async function runPreRegistration(serverUrl: string): Promise<void> {
370370

371371
registerScenario('auth/pre-registration', runPreRegistration);
372372

373+
// ============================================================================
374+
// Token refresh scenarios
375+
// ============================================================================
376+
377+
/**
378+
* Token refresh client: authenticates, makes a request, waits for the
379+
* short-lived access token to expire, then makes another request to
380+
* trigger the refresh_token grant.
381+
*/
382+
async function runTokenRefreshClient(serverUrl: string): Promise<void> {
383+
const client = new Client(
384+
{ name: 'test-token-refresh-client', version: '1.0.0' },
385+
{ capabilities: {} }
386+
);
387+
388+
const oauthFetch = withOAuthRetry(
389+
'test-token-refresh-client',
390+
new URL(serverUrl),
391+
handle401,
392+
CIMD_CLIENT_METADATA_URL
393+
)(fetch);
394+
395+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
396+
fetch: oauthFetch
397+
});
398+
399+
await client.connect(transport);
400+
logger.debug('Token refresh: connected');
401+
402+
// First request — should succeed with initial access token
403+
const tools = await client.listTools();
404+
logger.debug(`Token refresh: listTools returned ${tools.tools.length} tool(s)`);
405+
406+
if (tools.tools.length > 0) {
407+
await client.callTool({ name: tools.tools[0].name, arguments: {} });
408+
logger.debug('Token refresh: initial callTool succeeded');
409+
}
410+
411+
// Wait for the short-lived access token to expire (server uses 2s TTL)
412+
logger.debug('Token refresh: waiting 3s for token expiry...');
413+
await new Promise(resolve => setTimeout(resolve, 3000));
414+
415+
// Second request — should trigger 401 → refresh_token grant → retry
416+
const tools2 = await client.listTools();
417+
logger.debug(`Token refresh: post-expiry listTools returned ${tools2.tools.length} tool(s)`);
418+
419+
if (tools2.tools.length > 0) {
420+
await client.callTool({ name: tools2.tools[0].name, arguments: {} });
421+
logger.debug('Token refresh: post-expiry callTool succeeded');
422+
}
423+
424+
await transport.close();
425+
logger.debug('Token refresh: done');
426+
}
427+
428+
registerScenarios(
429+
['auth/token-refresh-basic', 'auth/token-refresh-rotation'],
430+
runTokenRefreshClient
431+
);
432+
373433
// ============================================================================
374434
// Main entry point
375435
// ============================================================================

src/scenarios/client/auth/helpers/createAuthServer.ts

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,32 @@ export interface AuthServerOptions {
4444
/** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */
4545
codeChallengeMethodsSupported?: string[] | null;
4646
tokenVerifier?: MockTokenVerifier;
47+
/**
48+
* Access token lifetime in seconds. Default: 3600 (1 hour).
49+
* Set to a small value (e.g. 2) for token refresh lifecycle tests.
50+
*/
51+
accessTokenExpiresIn?: number;
52+
/**
53+
* When true, the token endpoint returns a `refresh_token` alongside the
54+
* access token. Default: false (preserves existing behavior).
55+
* When enabled, the token endpoint also handles `grant_type=refresh_token`.
56+
*/
57+
issueRefreshToken?: boolean;
58+
/**
59+
* When true and `issueRefreshToken` is true, the server issues a new
60+
* refresh token on each refresh (token rotation per OAuth 2.1 §4.3.1).
61+
* Default: false (returns the same refresh token).
62+
*/
63+
rotateRefreshTokens?: boolean;
64+
/**
65+
* Called when the token endpoint receives a `grant_type=refresh_token`
66+
* request. Use this to emit conformance checks or control behavior.
67+
*/
68+
onRefreshTokenRequest?: (requestData: {
69+
refreshToken: string;
70+
scope?: string;
71+
timestamp: string;
72+
}) => void;
4773
onTokenRequest?: (requestData: {
4874
scope?: string;
4975
grantType: string;
@@ -87,6 +113,10 @@ export function createAuthServer(
87113
disableDynamicRegistration = false,
88114
codeChallengeMethodsSupported = ['S256'],
89115
tokenVerifier,
116+
accessTokenExpiresIn = 3600,
117+
issueRefreshToken = false,
118+
rotateRefreshTokens = false,
119+
onRefreshTokenRequest,
90120
onTokenRequest,
91121
onAuthorizationRequest,
92122
onRegistrationRequest
@@ -97,6 +127,19 @@ export function createAuthServer(
97127
// Track PKCE code_challenge for verification in token request
98128
let storedCodeChallenge: string | undefined;
99129

130+
// ── Refresh token state ──────────────────────────────────────────
131+
// Maps refresh_token → { scopes, generation }. Generation increments
132+
// on rotation so we can detect reuse of old tokens.
133+
let refreshTokenCounter = 0;
134+
const activeRefreshTokens = new Map<string, { scopes: string[]; generation: number }>();
135+
136+
function issueNewRefreshToken(scopes: string[]): string {
137+
refreshTokenCounter++;
138+
const token = `refresh-token-${refreshTokenCounter}-${Date.now()}`;
139+
activeRefreshTokens.set(token, { scopes, generation: refreshTokenCounter });
140+
return token;
141+
}
142+
100143
const authRoutes = {
101144
authorization_endpoint: `${routePrefix}/authorize`,
102145
token_endpoint: `${routePrefix}/token`,
@@ -320,6 +363,96 @@ export function createAuthServer(
320363
});
321364
}
322365

366+
// ── Handle refresh_token grant ──────────────────────────────────
367+
if (grantType === 'refresh_token') {
368+
const incomingRefreshToken = req.body.refresh_token as string | undefined;
369+
370+
checks.push({
371+
id: 'refresh-token-grant-received',
372+
name: 'RefreshTokenGrantReceived',
373+
description: incomingRefreshToken
374+
? 'Client sent grant_type=refresh_token with a refresh token'
375+
: 'Client sent grant_type=refresh_token but no refresh_token parameter',
376+
status: incomingRefreshToken ? 'SUCCESS' : 'FAILURE',
377+
timestamp,
378+
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
379+
details: {
380+
hasRefreshToken: !!incomingRefreshToken,
381+
}
382+
});
383+
384+
if (!incomingRefreshToken) {
385+
res.status(400).json({
386+
error: 'invalid_request',
387+
error_description: 'refresh_token parameter is required'
388+
});
389+
return;
390+
}
391+
392+
const storedEntry = activeRefreshTokens.get(incomingRefreshToken);
393+
if (!storedEntry) {
394+
checks.push({
395+
id: 'refresh-token-invalid',
396+
name: 'RefreshTokenInvalid',
397+
description: 'Client presented an unknown or revoked refresh token',
398+
status: 'INFO',
399+
timestamp,
400+
});
401+
res.status(400).json({
402+
error: 'invalid_grant',
403+
error_description: 'Refresh token is invalid, expired, or revoked'
404+
});
405+
return;
406+
}
407+
408+
if (onRefreshTokenRequest) {
409+
onRefreshTokenRequest({
410+
refreshToken: incomingRefreshToken,
411+
scope: requestedScope,
412+
timestamp,
413+
});
414+
}
415+
416+
// Issue new access token
417+
const newAccessToken = `test-token-refreshed-${Date.now()}`;
418+
const scopes = storedEntry.scopes;
419+
420+
// Register with verifier
421+
if (tokenVerifier) {
422+
tokenVerifier.registerToken(newAccessToken, scopes);
423+
}
424+
425+
// Optionally rotate the refresh token (OAuth 2.1 §4.3.1)
426+
let newRefreshToken: string | undefined;
427+
if (rotateRefreshTokens) {
428+
// Revoke old token
429+
activeRefreshTokens.delete(incomingRefreshToken);
430+
newRefreshToken = issueNewRefreshToken(scopes);
431+
432+
checks.push({
433+
id: 'refresh-token-rotated',
434+
name: 'RefreshTokenRotated',
435+
description: 'Server rotated refresh token per OAuth 2.1 §4.3.1',
436+
status: 'INFO',
437+
timestamp,
438+
details: {
439+
oldTokenPrefix: incomingRefreshToken.substring(0, 20) + '...',
440+
newTokenPrefix: newRefreshToken.substring(0, 20) + '...',
441+
}
442+
});
443+
}
444+
445+
res.json({
446+
access_token: newAccessToken,
447+
token_type: 'Bearer',
448+
expires_in: accessTokenExpiresIn,
449+
...(newRefreshToken && { refresh_token: newRefreshToken }),
450+
...(scopes.length > 0 && { scope: scopes.join(' ') })
451+
});
452+
return;
453+
}
454+
455+
// ── Handle authorization_code grant (existing logic) ─────────────
323456
let token = `test-token-${Date.now()}`;
324457
let scopes: string[] = lastAuthorizationScopes;
325458

@@ -352,12 +485,20 @@ export function createAuthServer(
352485
tokenVerifier.registerToken(token, scopes);
353486
}
354487

355-
res.json({
488+
// Build response with optional refresh token
489+
const tokenResponse: Record<string, unknown> = {
356490
access_token: token,
357491
token_type: 'Bearer',
358-
expires_in: 3600,
492+
expires_in: accessTokenExpiresIn,
359493
...(scopes.length > 0 && { scope: scopes.join(' ') })
360-
});
494+
};
495+
496+
if (issueRefreshToken) {
497+
const refreshToken = issueNewRefreshToken(scopes);
498+
tokenResponse.refresh_token = refreshToken;
499+
}
500+
501+
res.json(tokenResponse);
361502
});
362503

363504
app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => {

src/scenarios/client/auth/helpers/createServer.ts

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface ServerOptions {
2424
tokenVerifier?: MockTokenVerifier;
2525
/** Override the resource field in PRM response (for testing resource mismatch) */
2626
prmResourceOverride?: string;
27+
/**
28+
* When true, create a fresh MCP Server for each request instead of
29+
* reusing one. Required for token refresh tests where requests span
30+
* token expiry boundaries (the default behaviour calls server.close()
31+
* on response end, which breaks subsequent requests).
32+
*/
33+
perRequestServer?: boolean;
2734
}
2835

2936
export function createServer(
@@ -39,45 +46,54 @@ export function createServer(
3946
includePrmInWwwAuth = true,
4047
includeScopeInWwwAuth = false,
4148
tokenVerifier,
42-
prmResourceOverride
49+
prmResourceOverride,
50+
perRequestServer = false,
4351
} = options;
44-
const server = new Server(
45-
{
46-
name: 'auth-prm-pathbased-server',
47-
version: '1.0.0'
48-
},
49-
{
50-
capabilities: {
51-
tools: {}
52-
}
53-
}
54-
);
5552

56-
server.setRequestHandler(ListToolsRequestSchema, async () => {
57-
return {
58-
tools: [
59-
{
60-
name: 'test-tool',
61-
inputSchema: { type: 'object' }
53+
function createMcpServer(): Server {
54+
const srv = new Server(
55+
{
56+
name: 'auth-prm-pathbased-server',
57+
version: '1.0.0'
58+
},
59+
{
60+
capabilities: {
61+
tools: {}
6262
}
63-
]
64-
};
65-
});
63+
}
64+
);
65+
66+
srv.setRequestHandler(ListToolsRequestSchema, async () => {
67+
return {
68+
tools: [
69+
{
70+
name: 'test-tool',
71+
inputSchema: { type: 'object' }
72+
}
73+
]
74+
};
75+
});
6676

67-
server.setRequestHandler(
68-
CallToolRequestSchema,
69-
async (request): Promise<CallToolResult> => {
70-
if (request.params.name === 'test-tool') {
71-
return {
72-
content: [{ type: 'text', text: 'test' }]
73-
};
77+
srv.setRequestHandler(
78+
CallToolRequestSchema,
79+
async (request): Promise<CallToolResult> => {
80+
if (request.params.name === 'test-tool') {
81+
return {
82+
content: [{ type: 'text', text: 'test' }]
83+
};
84+
}
85+
throw new McpError(
86+
ErrorCode.InvalidParams,
87+
`Tool ${request.params.name} not found`
88+
);
7489
}
75-
throw new McpError(
76-
ErrorCode.InvalidParams,
77-
`Tool ${request.params.name} not found`
78-
);
79-
}
80-
);
90+
);
91+
92+
return srv;
93+
}
94+
95+
// For the default (non-per-request) mode, reuse a single server instance.
96+
const server = perRequestServer ? null : createMcpServer();
8197

8298
const app = express();
8399
app.use(express.json());
@@ -155,13 +171,18 @@ export function createServer(
155171
sessionIdGenerator: undefined
156172
});
157173

174+
// In per-request mode, create a fresh MCP server for each request
175+
// so that server.close() doesn't break subsequent requests across
176+
// token expiry boundaries.
177+
const srv = perRequestServer ? createMcpServer() : server!;
178+
158179
try {
159-
await server.connect(transport);
180+
await srv.connect(transport);
160181

161182
await transport.handleRequest(req, res, req.body);
162183
res.on('close', () => {
163184
transport.close();
164-
server.close();
185+
srv.close();
165186
});
166187
} catch (error) {
167188
console.error('Error handling MCP request:', error);

0 commit comments

Comments
 (0)