Merge pull request 'feat(auth): серверная API-key аутентификация агентов на /mcp — фаза 1 (#501)' (#526) from feat/501-mcp-apikey-auth into develop

Reviewed-on: #526
This commit was merged in pull request #526.
This commit is contained in:
2026-07-12 05:02:51 +03:00
30 changed files with 1881 additions and 171 deletions
@@ -70,6 +70,23 @@ export class EnvironmentService {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
// Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a
// deploy that never sets the variable must NOT silently kill every agent. The
// parse is STRICT — the value is validated by environment.validation to be
// exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False`
// fails at boot rather than being read as "enabled" (which would leave an
// operator who yanked the switch during an incident with it still on). When
// OFF: validate() denies every api-key token and the issuance endpoints 404.
isApiKeysEnabled(): boolean {
return this.configService.get<string>('API_KEYS_ENABLED', 'true') !== 'false';
}
// Raw value (or undefined) for the boot log, so the state after each deploy is
// verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`.
getApiKeysEnabledRaw(): string | undefined {
return this.configService.get<string>('API_KEYS_ENABLED');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
@@ -103,6 +103,15 @@ export class EnvironmentVariables {
@IsString()
TYPESENSE_LOCALE: string;
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
// instead of being silently read as "enabled" — the switch must actually flip
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
@IsOptional()
@IsIn(['true', 'false'])
@IsString()
API_KEYS_ENABLED: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@@ -304,37 +304,102 @@ export function clientIp(req: ClientIpRequest): string {
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<{
// The decoded payload shared by the /mcp Bearer allowlist. Carries the `type`
// discriminator and the API-key `apiKeyId`, on top of the access-token fields.
export interface McpBearerPayload {
type?: JwtType;
sub?: string;
email?: string;
workspaceId?: string;
sessionId?: string;
}> {
return (token: string) => tokenService.verifyJwt(token, JwtType.ACCESS);
apiKeyId?: string;
}
// Minimal structural shape of the TokenService.verifyJwtOneOf method.
export interface OneOfJwtVerifier {
verifyJwtOneOf: (
token: string,
allowed: JwtType[],
) => Promise<McpBearerPayload>;
}
/**
* Bind a TokenService-like verifier into a one-arg `verifyJwtOneOf(token)` that
* pins the /mcp Bearer ALLOWLIST to exactly {ACCESS, API_KEY}. This is the single
* place the /mcp Bearer path pins the token type: the /mcp Bearer slot
* legitimately accepts either an ACCESS token (a
* human's session token) OR an API_KEY token (an agent's key), but NOTHING else
* (collab/exchange/attachment/etc. are rejected with the generic type error).
* The allowlist is fixed here rather than at the call site, and the signature is
* verified exactly once (see verifyMcpBearer).
*/
export function bindMcpBearerVerifier(
tokenService: OneOfJwtVerifier,
): (token: string) => Promise<McpBearerPayload> {
return (token: string) =>
tokenService.verifyJwtOneOf(token, [JwtType.ACCESS, JwtType.API_KEY]);
}
// Deps for the /mcp Bearer router. `verifyJwtOneOf` is the one-arg verifier bound
// above (allowlist {ACCESS, API_KEY}); the ACCESS-specific revocation/disabled
// deps mirror BearerVerifyDeps; `validateApiKey` is the SHARED api-key row-check.
export interface McpBearerDeps
extends Omit<BearerVerifyDeps, 'verifyJwt'> {
verifyJwtOneOf: (token: string) => Promise<McpBearerPayload>;
// Row-check for an API_KEY principal — the SAME validator REST uses. Throws
// UnauthorizedException on a definite deny; PROPAGATES an infra error (→ 5xx),
// never masking it as a 401. Not a login attempt: the Basic limiter is not
// involved on this path.
validateApiKey: (payload: McpBearerPayload) => Promise<unknown>;
}
/**
* Verify a /mcp Bearer token that may be an ACCESS token OR an API_KEY token, and
* route by type. The signature is verified EXACTLY ONCE (verifyJwtOneOf); the
* result is reused so the ACCESS branch does not re-verify.
*
* - API_KEY -> bind to THIS instance's workspace FIRST (a token for another
* workspace is rejected), THEN run the shared `validateApiKey` row-check.
* No session/limiter involvement (an API key is not a login).
* - ACCESS -> the unchanged `verifyBearerAccess` (session-active + not-disabled
* checks), fed a closure over the already-verified payload so "verify once"
* and "helper unchanged" coexist.
*
* Throws UnauthorizedException on any auth failure (uniform generic message — no
* enumeration of why); propagates an infra error from `validateApiKey` as itself.
*/
export async function verifyMcpBearer(
token: string,
deps: McpBearerDeps,
): Promise<{ sub?: string; email?: string }> {
const generic = 'Invalid or expired token';
const payload = await deps.verifyJwtOneOf(token);
if (payload.type === JwtType.API_KEY) {
if (!payload.sub || !payload.workspaceId) {
throw new UnauthorizedException(generic);
}
// Instance-binding (mirrors verifyBearerAccess): reject an API_KEY token
// minted for a different workspace before touching the DB.
if (
deps.expectedWorkspaceId &&
payload.workspaceId !== deps.expectedWorkspaceId
) {
throw new UnauthorizedException(generic);
}
// Shared row-check. A definite deny throws Unauthorized; an infra error
// propagates (→ 5xx), which the caller must NOT convert to a 401.
await deps.validateApiKey(payload);
return { sub: payload.sub };
}
// ACCESS: reuse verifyBearerAccess WITHOUT re-verifying the signature.
return verifyBearerAccess(token, {
verifyJwt: async () => payload,
expectedWorkspaceId: deps.expectedWorkspaceId,
findUser: deps.findUser,
findActiveSession: deps.findActiveSession,
});
}
// Minimal shapes for the Bearer revocation/disabled check. Kept structural so
@@ -728,18 +793,24 @@ export async function resolveMcpSessionConfig(
};
}
// --- 2) fallback A: Bearer access-JWT (user-supplied token) ---
// --- 2) fallback A: Bearer JWT (user-supplied ACCESS or agent API_KEY) ---
const bearer = extractBearer(authHeader);
if (bearer) {
let payload: { sub?: string; email?: string };
try {
payload = await deps.verifyAccessJwt(bearer);
} catch (err) {
const message =
err instanceof Error && err.message
? err.message
: 'Invalid or expired token';
throw new UnauthorizedException(message);
// Anti-enumeration (Bearer leg): EVERY auth failure surfaces the SAME
// generic 401 — expired/revoked/wrong-type/unknown are indistinguishable
// to the caller (its reaction is identical either way). But an UNEXPECTED
// (infra) error is NOT an auth verdict: rethrow it AS ITSELF so the surface
// maps it to 5xx (mapAuthResultToResponse), never masking a DB/Redis
// outage as a bad token. verifyMcpBearer throws UnauthorizedException on a
// definite deny and lets an infra error from validateApiKey propagate.
if (err instanceof UnauthorizedException) {
throw new UnauthorizedException('Invalid or expired token');
}
throw err;
}
return {
config: { apiUrl, getToken: async () => bearer },
@@ -115,6 +115,7 @@ function makeService(opts: {
undefined as never, // userRepo
undefined as never, // userSessionRepo
moduleRef as never, // moduleRef (read by the MFA branch)
undefined as never, // apiKeyService (unused by the login-gate path)
undefined as never, // sandboxStore (unused by the login-gate path)
);
// Stop the constructor's unref'd sweep timer leaking across tests.
@@ -4,13 +4,16 @@ import { McpService } from './mcp.service';
import { DatabaseModule } from '@docmost/db/database.module';
import { AuthModule } from '../../core/auth/auth.module';
import { TokenModule } from '../../core/auth/token.module';
import { ApiKeyModule } from '../../core/api-key/api-key.module';
// Community MCP feature: the server itself serves the Model Context Protocol
// over HTTP at /mcp. DatabaseModule (global) provides WorkspaceRepo. AuthModule
// supplies AuthService (per-user HTTP-Basic login validation) and TokenModule
// supplies TokenService (Bearer access-JWT verification for the token fallback).
// supplies TokenService (Bearer JWT verification for the token path). ApiKeyModule
// supplies ApiKeyService (the shared api-key row-check for the API_KEY Bearer
// branch, so an agent authenticates with a key instead of the bcrypt Basic path).
@Module({
imports: [DatabaseModule, AuthModule, TokenModule],
imports: [DatabaseModule, AuthModule, TokenModule, ApiKeyModule],
controllers: [McpController],
providers: [McpService],
})
@@ -6,9 +6,10 @@ import {
isCredentialsFailure,
isInitializeRequestBody,
verifyBearerAccess,
verifyMcpBearer,
bindMcpBearerVerifier,
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
extractBearer,
decideBasicGate,
mapAuthResultToResponse,
@@ -524,13 +525,28 @@ describe('resolveMcpSessionConfig', () => {
expect(resolved.identity).toBe('bearer:user-9');
});
it('Bearer invalid -> specific 401 from verifyAccessJwt', async () => {
it('Bearer invalid -> UNIFORM generic 401 (anti-enumeration, reason not leaked)', async () => {
// #501: the Bearer leg no longer surfaces the specific reason. Whether the
// token is expired, revoked, wrong-type or unknown, the caller sees ONE bare
// 'Invalid or expired token' — an agent's reaction is identical, and a leaked
// class would be an enumeration oracle.
const verifyAccessJwt = jest
.fn()
.mockRejectedValue(new UnauthorizedException('jwt expired'));
await expect(
resolveMcpSessionConfig('Bearer expired', makeDeps({ verifyAccessJwt })),
).rejects.toThrow('jwt expired');
).rejects.toThrow('Invalid or expired token');
});
it('Bearer INFRA error -> propagates (NOT masked as 401)', async () => {
// A non-UnauthorizedException (e.g. a DB outage in the api-key row-check) is
// not an auth verdict: it must propagate so the surface maps it to 5xx.
const verifyAccessJwt = jest
.fn()
.mockRejectedValue(new Error('connection terminated'));
await expect(
resolveMcpSessionConfig('Bearer x', makeDeps({ verifyAccessJwt })),
).rejects.toThrow('connection terminated');
});
it('no creds + env service account configured -> service-account config', async () => {
@@ -1001,48 +1017,104 @@ describe('clientIp (XFF-fallback precedence, item 5)', () => {
});
});
describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
it('calls TokenService.verifyJwt with JwtType.ACCESS as the second argument', async () => {
// Mock TokenService: assert the type literal is pinned to ACCESS so swapping
// to REFRESH (or omitting the type) breaks this test.
const verifyJwt = jest
describe('bindMcpBearerVerifier pins the {ACCESS, API_KEY} allowlist (#501)', () => {
it('calls verifyJwtOneOf with exactly [ACCESS, API_KEY]', async () => {
const verifyJwtOneOf = jest
.fn()
.mockResolvedValue({ sub: 'user-1', workspaceId: 'ws-1' });
const verify = bindAccessJwtVerifier({ verifyJwt });
.mockResolvedValue({ type: JwtType.API_KEY, sub: 'u-1' });
await bindMcpBearerVerifier({ verifyJwtOneOf })('the.jwt');
expect(verifyJwtOneOf).toHaveBeenCalledWith('the.jwt', [
JwtType.ACCESS,
JwtType.API_KEY,
]);
// Pin the concrete enum values too.
expect(verifyJwtOneOf.mock.calls[0][1]).toEqual(['access', 'api_key']);
});
});
await verify('the.access.jwt');
expect(verifyJwt).toHaveBeenCalledTimes(1);
expect(verifyJwt).toHaveBeenCalledWith('the.access.jwt', JwtType.ACCESS);
// Pin the real enum value too, so renaming/repointing the enum member is caught.
expect(verifyJwt.mock.calls[0][1]).toBe('access');
describe('verifyMcpBearer routes by token type (#501)', () => {
const accessDeps = (over: any = {}) => ({
verifyJwtOneOf: jest.fn(),
expectedWorkspaceId: 'ws-1',
findUser: jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'u-1', workspaceId: 'ws-1' }),
validateApiKey: jest.fn(),
...over,
});
it('passes through the verified payload', async () => {
const payload = { sub: 'user-9', email: 'u@e.com', workspaceId: 'ws-1' };
const verifyJwt = jest.fn().mockResolvedValue(payload);
await expect(
bindAccessJwtVerifier({ verifyJwt })('t'),
).resolves.toBe(payload);
it('API_KEY -> row-checks via validateApiKey and does NOT touch session/limiter', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-1',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn().mockResolvedValue({ user: { id: 'svc-1' } }),
});
const res = await verifyMcpBearer('tok', deps);
expect(deps.validateApiKey).toHaveBeenCalledTimes(1);
// No session lookup on the api-key path (not a login).
expect(deps.findActiveSession).not.toHaveBeenCalled();
expect(res).toEqual({ sub: 'svc-1' });
});
// The Bearer revocation/disabled checks (verifyBearerAccess) are covered above;
// this binds the ACCESS-type enforcement that verifyMcpBearer wires in.
it('feeds verifyBearerAccess so the whole Bearer chain enforces ACCESS', async () => {
const verifyJwt = jest.fn().mockResolvedValue({
sub: 'user-1',
it('API_KEY for ANOTHER workspace -> rejected before the row-check', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-OTHER',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn(),
});
await expect(verifyMcpBearer('tok', deps)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(deps.validateApiKey).not.toHaveBeenCalled();
});
it('API_KEY infra error from validateApiKey PROPAGATES (not masked)', async () => {
const boom = new Error('db down');
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.API_KEY,
sub: 'svc-1',
workspaceId: 'ws-1',
apiKeyId: 'k-1',
}),
validateApiKey: jest.fn().mockRejectedValue(boom),
});
await expect(verifyMcpBearer('tok', deps)).rejects.toBe(boom);
});
it('ACCESS -> runs the session/disabled checks and does NOT call validateApiKey', async () => {
const deps = accessDeps({
verifyJwtOneOf: jest.fn().mockResolvedValue({
type: JwtType.ACCESS,
sub: 'u-1',
workspaceId: 'ws-1',
sessionId: 'sess-1',
email: 'u@e.com',
}),
});
const res = await verifyMcpBearer('tok', deps);
expect(deps.findActiveSession).toHaveBeenCalledWith('sess-1');
expect(deps.validateApiKey).not.toHaveBeenCalled();
expect(res).toEqual({ sub: 'u-1', email: 'u@e.com' });
});
it('verifies the signature exactly ONCE (single verifyJwtOneOf, no re-verify)', async () => {
const verifyJwtOneOf = jest.fn().mockResolvedValue({
type: JwtType.ACCESS,
sub: 'u-1',
workspaceId: 'ws-1',
sessionId: 'sess-1',
});
const res = await verifyBearerAccess('t', {
verifyJwt: bindAccessJwtVerifier({ verifyJwt }),
findUser: jest.fn().mockResolvedValue({ deactivatedAt: null }),
findActiveSession: jest
.fn()
.mockResolvedValue({ userId: 'user-1', workspaceId: 'ws-1' }),
});
expect(verifyJwt).toHaveBeenCalledWith('t', JwtType.ACCESS);
expect(res).toEqual({ sub: 'user-1', email: undefined });
await verifyMcpBearer('tok', accessDeps({ verifyJwtOneOf }));
expect(verifyJwtOneOf).toHaveBeenCalledTimes(1);
});
});
@@ -1198,6 +1270,7 @@ describe('McpService.onModuleDestroy — CollabSession teardown (#486)', () => {
{} as any,
{} as any,
{} as any,
{} as any,
);
}
+34 -25
View File
@@ -14,16 +14,17 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { AuthService } from '../../core/auth/services/auth.service';
import { TokenService } from '../../core/auth/services/token.service';
import { validateSsoEnforcement } from '../../core/auth/auth.util';
import { JwtPayload } from '../../core/auth/dto/jwt-payload';
import { JwtApiKeyPayload } from '../../core/auth/dto/jwt-payload';
import { Workspace } from '@docmost/db/types/entity.types';
import { ApiKeyService } from '../../core/api-key/api-key.service';
import {
FailedLoginLimiter,
resolveMcpSessionConfig,
verifyBearerAccess,
verifyMcpBearer,
isInitializeRequestBody,
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
bindMcpBearerVerifier,
decideBasicGate,
mapAuthResultToResponse,
DocmostMcpConfig,
@@ -105,6 +106,10 @@ export class McpService implements OnModuleDestroy {
private readonly userRepo: UserRepo,
private readonly userSessionRepo: UserSessionRepo,
private readonly moduleRef: ModuleRef,
// Shared api-key row-check for the /mcp API_KEY Bearer branch (same validator
// REST uses). Also lets an agent authenticate to /mcp with an api key instead
// of the bcrypt Basic path, so parallel reads stop starving the limiter.
private readonly apiKeyService: ApiKeyService,
// Shared singleton in-RAM blob store backing the stash tool.
private readonly sandboxStore: SandboxStore,
) {
@@ -194,37 +199,41 @@ export class McpService implements OnModuleDestroy {
}
}
// Bearer access-JWT verification for the /mcp token fallback. verifyJwt only
// checks signature/exp/type, but a logged-out (revoked) or disabled user can
// still hold an unexpired access JWT. JwtStrategy additionally checks the
// session is active and the user is not disabled; we mirror those exact checks
// here so the MCP Bearer path is not weaker than the normal cookie/header path.
// Bearer verification for the /mcp token path. The Bearer slot accepts EITHER
// an ACCESS token (a human session token) OR an API_KEY token (an agent's key)
// — the allowlist is pinned in bindMcpBearerVerifier. An ACCESS token is
// checked exactly as JwtStrategy does (signature/exp/type + session-active +
// not-disabled), so the MCP path is not weaker than the cookie/header path. An
// API_KEY token is HMAC-verified (microseconds) then row-checked via the shared
// ApiKeyService.validate — NOT a login attempt, so the Basic bcrypt path and
// its anti-brute-force limiter are never touched (the parallel-reads fix).
private async verifyMcpBearer(
token: string,
): Promise<{ sub?: string; email?: string }> {
// Resolve THIS instance's workspace so verifyBearerAccess can bind the
// token's `workspaceId` claim to it (mirrors JwtStrategy). The community
// build is single-workspace (findFirst), so this is the default workspace
// and the check is a no-op here; it only rejects a foreign-workspace token
// in a multi-workspace deployment. Undefined (no workspace configured) means
// no check — the credentials path would already have failed with no
// workspace, and an undefined here keeps the helper a no-op rather than
// rejecting every token.
// Resolve THIS instance's workspace so the router can bind the token's
// `workspaceId` claim to it (mirrors JwtStrategy). The community build is
// single-workspace (findFirst), so this is the default workspace and the
// check is a no-op here; it only rejects a foreign-workspace token in a
// multi-workspace deployment. Undefined (no workspace configured) means no
// check — the credentials path would already have failed with no workspace.
const instanceWorkspace = await this.workspaceRepo.findFirst();
// The revocation/disabled decision logic lives in the framework-free
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
// this method only wires in the concrete TokenService + repos.
return verifyBearerAccess(token, {
// The JwtType.ACCESS enforcement lives in bindAccessJwtVerifier (a pure,
// testable seam) so the type literal cannot silently drift to REFRESH.
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
t: string,
) => Promise<JwtPayload>,
// The type-routing + revocation/disabled decision logic lives in the
// framework-free verifyMcpBearer helper (unit-testable without the heavy auth
// graph); this method only wires in the concrete TokenService + repos + the
// shared api-key validator.
return verifyMcpBearer(token, {
// The {ACCESS, API_KEY} allowlist enforcement lives in bindMcpBearerVerifier
// (a pure, testable seam) so the type set cannot silently drift.
verifyJwtOneOf: bindMcpBearerVerifier(this.tokenService),
expectedWorkspaceId: instanceWorkspace?.id,
findUser: (sub, workspaceId) =>
this.userRepo.findById(sub, workspaceId),
findActiveSession: (sessionId) =>
this.userSessionRepo.findActiveById(sessionId),
// Shared with REST: a definite deny throws Unauthorized, an infra error
// propagates (→ 5xx). The /mcp bearer catch must preserve that distinction.
validateApiKey: (payload) =>
this.apiKeyService.validate(payload as JwtApiKeyPayload),
});
}