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