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
17 changes: 16 additions & 1 deletion packages/server-legacy/src/auth/middleware/clientAuth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { timingSafeEqual } from 'node:crypto';

import type { OAuthClientInformationFull } from '@modelcontextprotocol/core-internal';
import type { RequestHandler } from 'express';
import * as z from 'zod/v4';
Expand Down Expand Up @@ -26,6 +28,19 @@ declare module 'express-serve-static-core' {
}
}

/**
* Constant-time string comparison, to avoid leaking `client_secret` via a
* timing side channel. `timingSafeEqual` requires equal-length buffers, so a
* length mismatch is handled as an immediate non-match — this still leaks
* length, not content, the same tradeoff every constant-time-compare helper
* makes.
*/
function secretsMatch(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
return aBuf.length === bBuf.length && timingSafeEqual(aBuf, bBuf);
}

export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler {
return async (req, res, next) => {
try {
Expand All @@ -42,7 +57,7 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew
if (!client_secret) {
throw new InvalidClientError('Client secret is required');
}
if (client.client_secret !== client_secret) {
if (!secretsMatch(client.client_secret, client_secret)) {
throw new InvalidClientError('Invalid client_secret');
}
if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) {
Expand Down
14 changes: 14 additions & 0 deletions packages/server-legacy/test/auth/middleware/clientAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ describe('clientAuth middleware', () => {
expect(response.body.error_description).toBe('Invalid client_secret');
});

it('rejects invalid client_secret of a different length than the real one', async () => {
// Exercises the constant-time comparison's length-mismatch path
// (`timingSafeEqual` throws on unequal-length buffers, so this must
// be handled explicitly rather than left to throw).
const response = await supertest(app).post('/protected').send({
client_id: 'valid-client',
client_secret: 'short'
});

expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_client');
expect(response.body.error_description).toBe('Invalid client_secret');
});

it('rejects missing client_id', async () => {
const response = await supertest(app).post('/protected').send({
client_secret: 'valid-secret'
Expand Down
Loading