Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,66 @@ export async function runPreRegistration(serverUrl: string): Promise<void> {

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<void> {
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
// ============================================================================
Expand Down
147 changes: 144 additions & 3 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +113,10 @@ export function createAuthServer(
disableDynamicRegistration = false,
codeChallengeMethodsSupported = ['S256'],
tokenVerifier,
accessTokenExpiresIn = 3600,
issueRefreshToken = false,
rotateRefreshTokens = false,
onRefreshTokenRequest,
onTokenRequest,
onAuthorizationRequest,
onRegistrationRequest
Expand All @@ -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<string, { scopes: string[]; generation: number }>();

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`,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -352,12 +485,20 @@ export function createAuthServer(
tokenVerifier.registerToken(token, scopes);
}

res.json({
// Build response with optional refresh token
const tokenResponse: Record<string, unknown> = {
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) => {
Expand Down
93 changes: 57 additions & 36 deletions src/scenarios/client/auth/helpers/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<CallToolResult> => {
if (request.params.name === 'test-tool') {
return {
content: [{ type: 'text', text: 'test' }]
};
srv.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
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());
Expand Down Expand Up @@ -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);
Expand Down
Loading