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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user