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

@@ -2,3 +2,19 @@ export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
}
/**
* The single source of truth for the credentials-mismatch error message.
*
* `AuthService.verifyUserCredentials`/`login` throw an UnauthorizedException
* with EXACTLY this message for every credentials-failure case (unknown email,
* disabled user, wrong password). The /mcp Basic brute-force limiter relies on
* recognising that exact failure via `isCredentialsFailure` (mcp-auth.helpers),
* which matches against this same constant. Keeping a single shared constant
* means a reworded auth error cannot silently stop counting toward the limiter
* (which would turn /mcp Basic into an unthrottled password-guessing oracle).
* This file is intentionally dependency-light so it loads from both core/auth
* and the framework-free integrations/mcp helpers without dragging the heavy
* auth graph.
*/
export const CREDENTIALS_MISMATCH_MESSAGE = 'Email or password does not match';

View File

@@ -28,7 +28,7 @@ import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-e
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
import { PasswordResetDto } from '../dto/password-reset.dto';
import { User, UserToken, Workspace } from '@docmost/db/types/entity.types';
import { UserTokenType } from '../auth.constants';
import { UserTokenType, CREDENTIALS_MISMATCH_MESSAGE } from '../auth.constants';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
@@ -78,7 +78,9 @@ export class AuthService {
includePassword: true,
});
const errorMessage = 'Email or password does not match';
// Single source of truth (see auth.constants): the /mcp brute-force limiter
// recognises this exact message via isCredentialsFailure.
const errorMessage = CREDENTIALS_MISMATCH_MESSAGE;
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException(errorMessage);
}

View File

@@ -0,0 +1,103 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as ts from 'typescript';
/**
* Security contract for AuthService.verifyUserCredentials (item 4).
*
* verifyUserCredentials is the NON-side-effecting credential check used by the
* /mcp anti-fixation path on subsequent requests: it must perform the same
* lookup/password/email-verified/disabled checks as login() but mint NO session,
* write NO USER_LOGIN audit row and update NO lastLoginAt. Calling the
* side-effecting login() per /mcp tool call would be audit spam + a
* session-table DoS, so the no-side-effect property is load-bearing.
*
* Why this is a SOURCE-LEVEL (AST) contract test rather than a live AuthService
* unit: AuthService cannot be constructed — or even imported — under this jest
* config. jest is rooted at `src/` with no `^src/(.*)` moduleNameMapper, so the
* transitive `import ... from 'src/integrations/queue/constants'` chain
* (AuthService -> SignupService -> WorkspaceService -> SpaceService) does not
* resolve; and even with that mapped, importing AuthService pulls in the
* `@docmost/transactional` React email templates and the lib0/ESM collaboration
* graph, which jest's ts-jest transform (with the repo's transformIgnorePatterns)
* cannot load. (The pre-existing auth.service.spec.ts placeholder fails to run
* for exactly this reason.) So we assert the contract STRUCTURALLY against the
* real source: verifyUserCredentials must contain none of the three side
* effects, and login() must contain all three — a regression that adds a side
* effect to verifyUserCredentials, or drops one from login, fails this test.
*/
const SIDE_EFFECTS = [
// session/token mint (user_sessions insert + JWT)
'createSessionAndToken',
// USER_LOGIN audit event (precise call expression, not a bare "log")
'auditService.log',
// lastLoginAt bump
'updateLastLogin',
] as const;
function methodBodyText(source: string, methodName: string): string {
const sf = ts.createSourceFile(
'auth.service.ts',
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
);
let found: string | null = null;
const visit = (node: ts.Node): void => {
if (
ts.isMethodDeclaration(node) &&
node.name &&
ts.isIdentifier(node.name) &&
node.name.text === methodName &&
node.body
) {
found = node.body.getText(sf);
return;
}
ts.forEachChild(node, visit);
};
visit(sf);
if (found === null) {
throw new Error(`method ${methodName} not found in auth.service.ts`);
}
return found;
}
describe('AuthService no-side-effect contract (item 4)', () => {
const sourcePath = path.join(__dirname, 'auth.service.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
const verifyBody = methodBodyText(source, 'verifyUserCredentials');
const loginBody = methodBodyText(source, 'login');
it('verifyUserCredentials performs NONE of the side effects', () => {
// No session/token mint, no audit log write, no lastLoginAt update.
expect(verifyBody).not.toContain('createSessionAndToken');
expect(verifyBody).not.toContain('updateLastLogin');
expect(verifyBody).not.toContain('auditService.log');
// It still does the real credential work (lookup + password compare).
expect(verifyBody).toContain('findByEmail');
expect(verifyBody).toContain('comparePasswordHash');
// ...and returns the matched user (so login() can reuse it).
expect(verifyBody).toContain('return user');
});
it('login() performs ALL three side effects', () => {
expect(loginBody).toContain('updateLastLogin');
expect(loginBody).toContain('auditService.log');
expect(loginBody).toContain('createSessionAndToken');
// login() reuses verifyUserCredentials, so there is no behaviour drift
// between the side-effecting and non-side-effecting credential paths.
expect(loginBody).toContain('verifyUserCredentials');
});
it('every side effect that login() has is ABSENT from verifyUserCredentials', () => {
for (const effect of SIDE_EFFECTS) {
expect(loginBody.includes(effect)).toBe(true);
expect(verifyBody.includes(effect)).toBe(false);
}
});
});