From 5d26671bc0c3d2efef2a7969770cb0774c12f1bd Mon Sep 17 00:00:00 2001 From: JD Maturen <70791+jdmaturen@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:06:04 -0800 Subject: [PATCH 1/2] fix: use InvalidTokenError instead of generic Error in MockTokenVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scenarios/client/auth/helpers/mockTokenVerifier.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 8cbfae1..022fa4d 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -1,5 +1,6 @@ import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import type { ConformanceCheck } from '../../../../types'; import { SpecReferences } from '../spec-references'; @@ -53,6 +54,6 @@ export class MockTokenVerifier implements OAuthTokenVerifier { token: token ? token.substring(0, 10) + '...' : 'missing' } }); - throw new Error('Invalid token'); + throw new InvalidTokenError('Invalid token'); } } From 2ef97b5a315b182d257808543284432b98ff21b5 Mon Sep 17 00:00:00 2001 From: JD Maturen <70791+jdmaturen@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:08:25 -0800 Subject: [PATCH 2/2] feat: add token refresh and rotation conformance scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #138 (InvalidTokenError fix). Co-authored-by: Cursor --- .../clients/typescript/everything-client.ts | 60 ++++ .../client/auth/helpers/createAuthServer.ts | 147 +++++++++- .../client/auth/helpers/createServer.ts | 93 +++--- .../client/auth/helpers/mockTokenVerifier.ts | 65 ++++- src/scenarios/client/auth/index.ts | 8 +- src/scenarios/client/auth/spec-references.ts | 8 + src/scenarios/client/auth/token-refresh.ts | 275 ++++++++++++++++++ 7 files changed, 610 insertions(+), 46 deletions(-) create mode 100644 src/scenarios/client/auth/token-refresh.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 93fd142..b4b1a9b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -370,6 +370,66 @@ export async function runPreRegistration(serverUrl: string): Promise { registerScenario('auth/pre-registration', runPreRegistration); +// ============================================================================ +// Token refresh scenarios +// ============================================================================ + +/** + * Token refresh client: authenticates, makes a request, waits for the + * short-lived access token to expire, then makes another request to + * trigger the refresh_token grant. + */ +async function runTokenRefreshClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-token-refresh-client', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-token-refresh-client', + new URL(serverUrl), + handle401, + CIMD_CLIENT_METADATA_URL + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Token refresh: connected'); + + // First request — should succeed with initial access token + const tools = await client.listTools(); + logger.debug(`Token refresh: listTools returned ${tools.tools.length} tool(s)`); + + if (tools.tools.length > 0) { + await client.callTool({ name: tools.tools[0].name, arguments: {} }); + logger.debug('Token refresh: initial callTool succeeded'); + } + + // Wait for the short-lived access token to expire (server uses 2s TTL) + logger.debug('Token refresh: waiting 3s for token expiry...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Second request — should trigger 401 → refresh_token grant → retry + const tools2 = await client.listTools(); + logger.debug(`Token refresh: post-expiry listTools returned ${tools2.tools.length} tool(s)`); + + if (tools2.tools.length > 0) { + await client.callTool({ name: tools2.tools[0].name, arguments: {} }); + logger.debug('Token refresh: post-expiry callTool succeeded'); + } + + await transport.close(); + logger.debug('Token refresh: done'); +} + +registerScenarios( + ['auth/token-refresh-basic', 'auth/token-refresh-rotation'], + runTokenRefreshClient +); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 8621490..74ff63c 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -44,6 +44,32 @@ export interface AuthServerOptions { /** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */ codeChallengeMethodsSupported?: string[] | null; tokenVerifier?: MockTokenVerifier; + /** + * Access token lifetime in seconds. Default: 3600 (1 hour). + * Set to a small value (e.g. 2) for token refresh lifecycle tests. + */ + accessTokenExpiresIn?: number; + /** + * When true, the token endpoint returns a `refresh_token` alongside the + * access token. Default: false (preserves existing behavior). + * When enabled, the token endpoint also handles `grant_type=refresh_token`. + */ + issueRefreshToken?: boolean; + /** + * When true and `issueRefreshToken` is true, the server issues a new + * refresh token on each refresh (token rotation per OAuth 2.1 §4.3.1). + * Default: false (returns the same refresh token). + */ + rotateRefreshTokens?: boolean; + /** + * Called when the token endpoint receives a `grant_type=refresh_token` + * request. Use this to emit conformance checks or control behavior. + */ + onRefreshTokenRequest?: (requestData: { + refreshToken: string; + scope?: string; + timestamp: string; + }) => void; onTokenRequest?: (requestData: { scope?: string; grantType: string; @@ -87,6 +113,10 @@ export function createAuthServer( disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], tokenVerifier, + accessTokenExpiresIn = 3600, + issueRefreshToken = false, + rotateRefreshTokens = false, + onRefreshTokenRequest, onTokenRequest, onAuthorizationRequest, onRegistrationRequest @@ -97,6 +127,19 @@ export function createAuthServer( // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; + // ── Refresh token state ────────────────────────────────────────── + // Maps refresh_token → { scopes, generation }. Generation increments + // on rotation so we can detect reuse of old tokens. + let refreshTokenCounter = 0; + const activeRefreshTokens = new Map(); + + function issueNewRefreshToken(scopes: string[]): string { + refreshTokenCounter++; + const token = `refresh-token-${refreshTokenCounter}-${Date.now()}`; + activeRefreshTokens.set(token, { scopes, generation: refreshTokenCounter }); + return token; + } + const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, token_endpoint: `${routePrefix}/token`, @@ -320,6 +363,96 @@ export function createAuthServer( }); } + // ── Handle refresh_token grant ────────────────────────────────── + if (grantType === 'refresh_token') { + const incomingRefreshToken = req.body.refresh_token as string | undefined; + + checks.push({ + id: 'refresh-token-grant-received', + name: 'RefreshTokenGrantReceived', + description: incomingRefreshToken + ? 'Client sent grant_type=refresh_token with a refresh token' + : 'Client sent grant_type=refresh_token but no refresh_token parameter', + status: incomingRefreshToken ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SpecReferences.OAUTH_2_1_TOKEN], + details: { + hasRefreshToken: !!incomingRefreshToken, + } + }); + + if (!incomingRefreshToken) { + res.status(400).json({ + error: 'invalid_request', + error_description: 'refresh_token parameter is required' + }); + return; + } + + const storedEntry = activeRefreshTokens.get(incomingRefreshToken); + if (!storedEntry) { + checks.push({ + id: 'refresh-token-invalid', + name: 'RefreshTokenInvalid', + description: 'Client presented an unknown or revoked refresh token', + status: 'INFO', + timestamp, + }); + res.status(400).json({ + error: 'invalid_grant', + error_description: 'Refresh token is invalid, expired, or revoked' + }); + return; + } + + if (onRefreshTokenRequest) { + onRefreshTokenRequest({ + refreshToken: incomingRefreshToken, + scope: requestedScope, + timestamp, + }); + } + + // Issue new access token + const newAccessToken = `test-token-refreshed-${Date.now()}`; + const scopes = storedEntry.scopes; + + // Register with verifier + if (tokenVerifier) { + tokenVerifier.registerToken(newAccessToken, scopes); + } + + // Optionally rotate the refresh token (OAuth 2.1 §4.3.1) + let newRefreshToken: string | undefined; + if (rotateRefreshTokens) { + // Revoke old token + activeRefreshTokens.delete(incomingRefreshToken); + newRefreshToken = issueNewRefreshToken(scopes); + + checks.push({ + id: 'refresh-token-rotated', + name: 'RefreshTokenRotated', + description: 'Server rotated refresh token per OAuth 2.1 §4.3.1', + status: 'INFO', + timestamp, + details: { + oldTokenPrefix: incomingRefreshToken.substring(0, 20) + '...', + newTokenPrefix: newRefreshToken.substring(0, 20) + '...', + } + }); + } + + res.json({ + access_token: newAccessToken, + token_type: 'Bearer', + expires_in: accessTokenExpiresIn, + ...(newRefreshToken && { refresh_token: newRefreshToken }), + ...(scopes.length > 0 && { scope: scopes.join(' ') }) + }); + return; + } + + // ── Handle authorization_code grant (existing logic) ───────────── let token = `test-token-${Date.now()}`; let scopes: string[] = lastAuthorizationScopes; @@ -352,12 +485,20 @@ export function createAuthServer( tokenVerifier.registerToken(token, scopes); } - res.json({ + // Build response with optional refresh token + const tokenResponse: Record = { access_token: token, token_type: 'Bearer', - expires_in: 3600, + expires_in: accessTokenExpiresIn, ...(scopes.length > 0 && { scope: scopes.join(' ') }) - }); + }; + + if (issueRefreshToken) { + const refreshToken = issueNewRefreshToken(scopes); + tokenResponse.refresh_token = refreshToken; + } + + res.json(tokenResponse); }); app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => { diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 35b89b1..16d4be7 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -24,6 +24,13 @@ export interface ServerOptions { tokenVerifier?: MockTokenVerifier; /** Override the resource field in PRM response (for testing resource mismatch) */ prmResourceOverride?: string; + /** + * When true, create a fresh MCP Server for each request instead of + * reusing one. Required for token refresh tests where requests span + * token expiry boundaries (the default behaviour calls server.close() + * on response end, which breaks subsequent requests). + */ + perRequestServer?: boolean; } export function createServer( @@ -39,45 +46,54 @@ export function createServer( includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier, - prmResourceOverride + prmResourceOverride, + perRequestServer = false, } = options; - const server = new Server( - { - name: 'auth-prm-pathbased-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'test-tool', - inputSchema: { type: 'object' } + function createMcpServer(): Server { + const srv = new Server( + { + name: 'auth-prm-pathbased-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} } - ] - }; - }); + } + ); + + srv.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'test-tool', + inputSchema: { type: 'object' } + } + ] + }; + }); - server.setRequestHandler( - CallToolRequestSchema, - async (request): Promise => { - if (request.params.name === 'test-tool') { - return { - content: [{ type: 'text', text: 'test' }] - }; + srv.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + if (request.params.name === 'test-tool') { + return { + content: [{ type: 'text', text: 'test' }] + }; + } + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} not found` + ); } - throw new McpError( - ErrorCode.InvalidParams, - `Tool ${request.params.name} not found` - ); - } - ); + ); + + return srv; + } + + // For the default (non-per-request) mode, reuse a single server instance. + const server = perRequestServer ? null : createMcpServer(); const app = express(); app.use(express.json()); @@ -155,13 +171,18 @@ export function createServer( sessionIdGenerator: undefined }); + // In per-request mode, create a fresh MCP server for each request + // so that server.close() doesn't break subsequent requests across + // token expiry boundaries. + const srv = perRequestServer ? createMcpServer() : server!; + try { - await server.connect(transport); + await srv.connect(transport); await transport.handleRequest(req, res, req.body); res.on('close', () => { transport.close(); - server.close(); + srv.close(); }); } catch (error) { console.error('Error handling MCP request:', error); diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 022fa4d..f238bb7 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -4,23 +4,71 @@ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors. import type { ConformanceCheck } from '../../../../types'; import { SpecReferences } from '../spec-references'; +interface TokenEntry { + scopes: string[]; + /** Unix timestamp (seconds) when the token was issued. */ + issuedAt: number; + /** Token lifetime in seconds. */ + expiresIn: number; +} + export class MockTokenVerifier implements OAuthTokenVerifier { - private tokenScopes: Map = new Map(); + private tokenEntries: Map = new Map(); + + /** + * Default token lifetime for registerToken calls that don't specify one. + * Set to a small value for token refresh lifecycle tests. + */ + public defaultExpiresIn = 3600; constructor( private checks: ConformanceCheck[], private expectedScopes: string[] = [] ) {} - registerToken(token: string, scopes: string[]) { - this.tokenScopes.set(token, scopes); + registerToken(token: string, scopes: string[], expiresIn?: number) { + this.tokenEntries.set(token, { + scopes, + issuedAt: Math.floor(Date.now() / 1000), + expiresIn: expiresIn ?? this.defaultExpiresIn, + }); + } + + /** Legacy getter for code that reads token scopes directly. */ + get tokenScopes(): Map { + const result = new Map(); + for (const [token, entry] of this.tokenEntries) { + result.set(token, entry.scopes); + } + return result; } async verifyAccessToken(token: string): Promise { // Accept tokens that start with known prefixes if (token.startsWith('test-token') || token.startsWith('cc-token')) { - // Get scopes for this token, or use empty array - const scopes = this.tokenScopes.get(token) || []; + const entry = this.tokenEntries.get(token); + const scopes = entry?.scopes || []; + + // Check expiration if entry exists with a finite lifetime + if (entry) { + const now = Math.floor(Date.now() / 1000); + const expiresAt = entry.issuedAt + entry.expiresIn; + if (now > expiresAt) { + this.checks.push({ + id: 'expired-bearer-token', + name: 'ExpiredBearerToken', + description: 'Client presented an expired access token', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], + details: { + token: token.substring(0, 15) + '...', + expiredSecondsAgo: now - expiresAt, + } + }); + throw new InvalidTokenError('Token expired'); + } + } this.checks.push({ id: 'valid-bearer-token', @@ -34,11 +82,16 @@ export class MockTokenVerifier implements OAuthTokenVerifier { scopes } }); + + const expiresAt = entry + ? entry.issuedAt + entry.expiresIn + : Math.floor(Date.now() / 1000) + 3600; + return { token, clientId: 'test-client', scopes, - expiresAt: Math.floor(Date.now() / 1000) + 3600 + expiresAt, }; } diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7f75113..a39aa16 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,6 +23,10 @@ import { } from './client-credentials'; import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; +import { + TokenRefreshBasicScenario, + TokenRefreshRotationScenario +} from './token-refresh'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -37,7 +41,9 @@ export const authScenariosList: Scenario[] = [ new ClientSecretPostAuthScenario(), new PublicClientAuthScenario(), new ResourceMismatchScenario(), - new PreRegistrationScenario() + new PreRegistrationScenario(), + new TokenRefreshBasicScenario(), + new TokenRefreshRotationScenario() ]; // Back-compat scenarios (optional - backward compatibility with older spec versions) diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index a24e987..5f50c5b 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -88,5 +88,13 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_PKCE: { id: 'MCP-PKCE-requirement', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' + }, + OAUTH_2_1_REFRESH_TOKEN: { + id: 'OAUTH-2.1-refresh-token', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-6' + }, + OAUTH_2_1_TOKEN_ROTATION: { + id: 'OAUTH-2.1-token-rotation', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-6.1' } }; diff --git a/src/scenarios/client/auth/token-refresh.ts b/src/scenarios/client/auth/token-refresh.ts new file mode 100644 index 0000000..b6e59c7 --- /dev/null +++ b/src/scenarios/client/auth/token-refresh.ts @@ -0,0 +1,275 @@ +import type { Scenario, ConformanceCheck } from '../../../types'; +import { ScenarioUrls } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; + +/** + * Scenario: Token Refresh Basic + * + * Tests that clients correctly use the refresh_token grant to obtain a new + * access token when the current one expires. + * + * Flow: + * 1. Client authenticates via authorization_code grant + * 2. Server issues access_token (short-lived, 2s) + refresh_token + * 3. Client makes a successful MCP request (tools/list) + * 4. Access token expires + * 5. Client's next request gets 401 + * 6. Client MUST send grant_type=refresh_token to the token endpoint + * 7. Client MUST use the new access_token for subsequent requests + * + * Spec references: + * - OAuth 2.1 §6: Refreshing an Access Token + * - OAuth 2.1 §4.3.1: Token rotation for public clients + */ +export class TokenRefreshBasicScenario implements Scenario { + name = 'auth/token-refresh-basic'; + description = + 'Tests that client uses refresh_token grant to obtain new access token when current one expires (OAuth 2.1 §6)'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + // Short-lived tokens — 2 seconds + tokenVerifier.defaultExpiresIn = 2; + + let refreshGrantCount = 0; + let refreshedAccessTokenUsed = false; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + accessTokenExpiresIn: 2, + issueRefreshToken: true, + rotateRefreshTokens: false, + onRefreshTokenRequest: (data) => { + refreshGrantCount++; + this.checks.push({ + id: 'refresh-token-grant-used', + name: 'Client used refresh_token grant', + description: + 'Client correctly used grant_type=refresh_token to obtain new access token after expiry', + status: 'SUCCESS', + timestamp: data.timestamp, + specReferences: [SpecReferences.OAUTH_2_1_REFRESH_TOKEN], + details: { + refreshAttempt: refreshGrantCount, + refreshTokenPrefix: data.refreshToken.substring(0, 20) + '...', + } + }); + }, + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier, + perRequestServer: true, + } + ); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Check if the client ever used the refresh_token grant + const hasRefreshCheck = this.checks.some( + (c) => c.id === 'refresh-token-grant-used' + ); + const hasRefreshGrant = this.checks.some( + (c) => c.id === 'refresh-token-grant-received' + ); + + if (!hasRefreshGrant && !hasRefreshCheck) { + this.checks.push({ + id: 'refresh-token-grant-used', + name: 'Client used refresh_token grant', + description: + 'Client did not use grant_type=refresh_token after access token expired. ' + + 'Clients MUST use refresh tokens when available (OAuth 2.1 §6).', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_REFRESH_TOKEN], + }); + } + + // Check if the client successfully used a refreshed token + const validTokenChecks = this.checks.filter( + (c) => c.id === 'valid-bearer-token' + ); + const expiredTokenChecks = this.checks.filter( + (c) => c.id === 'expired-bearer-token' + ); + + if (validTokenChecks.length >= 2 && hasRefreshCheck) { + this.checks.push({ + id: 'refreshed-token-used-successfully', + name: 'Refreshed access token used successfully', + description: + 'Client obtained a new access token via refresh and used it for a subsequent MCP request', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_REFRESH_TOKEN], + details: { + totalValidTokenUses: validTokenChecks.length, + totalExpiredTokenRejections: expiredTokenChecks.length, + } + }); + } + + return this.checks; + } +} + +/** + * Scenario: Token Refresh with Rotation + * + * Tests that clients store rotated refresh tokens when the server issues + * a new refresh_token in the token response (OAuth 2.1 §4.3.1). + * + * Flow: + * 1-6. Same as TokenRefreshBasic + * 7. Server returns a NEW refresh_token alongside the new access_token + * 8. Client MUST store the new refresh_token + * 9. When the new access_token expires, client uses the NEW refresh_token + * + * If the client reuses the OLD refresh_token, the server rejects it. + */ +export class TokenRefreshRotationScenario implements Scenario { + name = 'auth/token-refresh-rotation'; + description = + 'Tests that client stores rotated refresh tokens per OAuth 2.1 §4.3.1'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + tokenVerifier.defaultExpiresIn = 2; + + let refreshGrantCount = 0; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + accessTokenExpiresIn: 2, + issueRefreshToken: true, + rotateRefreshTokens: true, + onRefreshTokenRequest: (data) => { + refreshGrantCount++; + this.checks.push({ + id: `refresh-rotation-attempt-${refreshGrantCount}`, + name: `Refresh attempt ${refreshGrantCount} (with rotation)`, + description: + `Client sent refresh_token grant (attempt ${refreshGrantCount}). ` + + 'Server will rotate the refresh token.', + status: 'SUCCESS', + timestamp: data.timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_REFRESH_TOKEN, + SpecReferences.OAUTH_2_1_TOKEN_ROTATION, + ], + details: { + refreshTokenPrefix: data.refreshToken.substring(0, 20) + '...', + attemptNumber: refreshGrantCount, + } + }); + }, + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier, + perRequestServer: true, + } + ); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const rotationAttempts = this.checks.filter( + (c) => c.id.startsWith('refresh-rotation-attempt-') + ); + const invalidRefreshChecks = this.checks.filter( + (c) => c.id === 'refresh-token-invalid' + ); + + if (rotationAttempts.length === 0) { + this.checks.push({ + id: 'refresh-rotation-result', + name: 'Token rotation test result', + description: + 'Client did not attempt to use refresh_token grant. ' + + 'Cannot test token rotation without refresh flow.', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_TOKEN_ROTATION], + }); + } else if (invalidRefreshChecks.length > 0) { + this.checks.push({ + id: 'refresh-rotation-result', + name: 'Token rotation test result', + description: + 'Client reused an old refresh token after rotation. ' + + 'Clients MUST store the new refresh_token when the server rotates it.', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_TOKEN_ROTATION], + details: { + totalRefreshAttempts: rotationAttempts.length, + invalidRefreshTokenUses: invalidRefreshChecks.length, + } + }); + } else if (rotationAttempts.length >= 1) { + this.checks.push({ + id: 'refresh-rotation-result', + name: 'Token rotation test result', + description: + 'Client correctly stored and used rotated refresh token', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_TOKEN_ROTATION], + details: { + totalRefreshAttempts: rotationAttempts.length, + } + }); + } + + return this.checks; + } +}