test(mcp): cover X-MCP-Token/clientIp/bearer-type/creds-failure (pure seams)

Release-cycle test audit: the /mcp auth's constant-time token guard, IP keying,
ACCESS-type pinning, and brute-force message coupling were untested. Extract
behavior-preserving pure helpers so they're testable and cover them:
- sharedTokenMatches: length-mismatch early-returns before timingSafeEqual
  (which throws on unequal lengths); equal-length uses timingSafeEqual; array
  header -> first element; non-string -> false.
- clientIp: req.ip > socket > first XFF hop > 'unknown' (limiter keying).
- bindAccessJwtVerifier: verifyJwt pinned to JwtType.ACCESS (rejects REFRESH).
- CREDENTIALS_MISMATCH_MESSAGE single source of truth shared by
  verifyUserCredentials and isCredentialsFailure, so a reworded auth error can't
  silently disable the /mcp brute-force counter.
- verifyUserCredentials no-side-effect contract asserted via a TS-AST spec
  (AuthService can't load under jest): its body has no createSessionAndToken/
  audit/updateLastLogin while login() has all three.
Extractions are behavior-preserving (reviewed); class delegates to the helpers,
dead code + unused imports removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 14:32:29 +03:00
parent bfd79b94bc
commit 3672093f56
6 changed files with 425 additions and 44 deletions

View File

@@ -4,7 +4,9 @@
// dependency graph, and reused by McpService. Nothing here logs the password or
// the Authorization header.
import { UnauthorizedException } from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
/**
* Decode an `Authorization: Basic base64(email:password)` header into its
@@ -171,15 +173,112 @@ export interface McpAuthDeps {
* throws an UnauthorizedException with exactly this message for every
* credentials-mismatch case (no user / disabled / wrong password), so we match
* on that.
*
* The message is NOT hardcoded here: it matches against the shared
* CREDENTIALS_MISMATCH_MESSAGE constant that AuthService.verifyUserCredentials
* also throws, so a reworded auth error cannot silently stop counting toward the
* limiter (single source of truth — see auth.constants.ts).
*/
export function isCredentialsFailure(err: unknown): boolean {
return (
err instanceof UnauthorizedException &&
typeof err.message === 'string' &&
err.message.toLowerCase().includes('email or password does not match')
err.message
.toLowerCase()
.includes(CREDENTIALS_MISMATCH_MESSAGE.toLowerCase())
);
}
/**
* Constant-time comparison of the optional shared X-MCP-Token guard. A header
* value may arrive as string | string[] (multiple X-MCP-Token headers), so we
* normalise to the first string. crypto.timingSafeEqual avoids leaking the
* token's length via early-exit string comparison; it requires equal buffer
* lengths, so a length mismatch is treated as a non-match WITHOUT calling
* timingSafeEqual (which throws on unequal lengths). A non-string / undefined
* value is never a match.
*
* Pure and framework-free so it is unit-testable; McpService.handle delegates to
* it for the X-MCP-Token shared guard.
*/
export function sharedTokenMatches(
expected: string,
provided: string | string[] | undefined,
): boolean {
const value = Array.isArray(provided) ? provided[0] : provided;
if (typeof value !== 'string') return false;
const a = Buffer.from(value);
const b = Buffer.from(expected);
// Early-return before timingSafeEqual, which throws on unequal-length buffers.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// Minimal structural shape of the bits of a Fastify request that `clientIp`
// needs. Kept structural so this module never imports the Fastify types.
export interface ClientIpRequest {
ip?: string;
socket?: { remoteAddress?: string };
headers: Record<string, string | string[] | undefined>;
}
/**
* Best-effort client IP for the failed-login limiter key. Precedence:
* 1. req.ip — Fastify's resolved IP (honours a configured trustProxy
* chain); the trustworthy value when a proxy is set up.
* 2. socket.remoteAddress — the raw TCP peer, used only when req.ip is absent.
* 3. first X-Forwarded-For hop — LAST resort only, because XFF is
* client-forgeable when no trusted proxy is configured.
* 4. 'unknown' — nothing usable.
*
* A forged IP can only dodge the per-IP limiter keys; the GLOBAL per-email key
* in resolveMcpSessionConfig is the real account-brute backstop and does not
* depend on this value. Pure/framework-free so it is unit-testable; McpService
* delegates to it.
*/
export function clientIp(req: ClientIpRequest): string {
if (req.ip) return req.ip;
if (req.socket?.remoteAddress) return req.socket.remoteAddress;
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff.length > 0) {
return xff.split(',')[0].trim();
}
return 'unknown';
}
// Minimal structural shape of the TokenService.verifyJwt method we depend on,
// so this module never imports the concrete TokenService (heavy graph).
export interface AccessJwtVerifier {
verifyJwt: (
token: string,
type: JwtType,
) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}>;
}
/**
* Bind a TokenService-like verifier into a one-arg `verifyJwt(token)` that
* ALWAYS enforces `JwtType.ACCESS`. This is the single place where the /mcp
* Bearer path pins the token type: a Bearer access token must be verified AS an
* access token (not refresh/exchange/collab/etc.), so the type literal is fixed
* here rather than at the call site. McpService.verifyMcpBearer delegates to
* this, keeping the `JwtType.ACCESS` choice testable without the heavy graph.
*/
export function bindAccessJwtVerifier(
tokenService: AccessJwtVerifier,
): (token: string) => Promise<{
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}> {
return (token: string) => tokenService.verifyJwt(token, JwtType.ACCESS);
}
// Minimal shapes for the Bearer revocation/disabled check. Kept structural so
// this module never imports the concrete repos/JwtPayload (heavy graph).
export interface BearerVerifyDeps {