feat(mcp): per-user auth for the embedded /mcp endpoint

The embedded MCP server acted as a single service account; now each /mcp
session authenticates as the current user, so tools run under that user's
CASL and edits attribute to them.

- HTTP Basic (chosen path): Authorization: Basic email:password, validated
  server-side via AuthService; the session carries the issued user JWT (not
  the raw password). Password may contain ':' (split on first only).
- Bearer fallback: Authorization: Bearer <access JWT>, verified as ACCESS and
  additionally checked for an active session + non-disabled user (matching
  JwtStrategy), so revoked/disabled users are rejected.
- Service account stays as an optional fallback (no creds + env configured).
- packages/mcp createMcpHttpHandler accepts a per-request config resolver
  (back-compat: static config / stdio unchanged); identity is bound to the
  mcp-session-id at init and re-validated from the caller's own credentials on
  every request (anti session-fixation: a guessed session id can't be reused
  without matching creds).
- A full login (session + audit) happens only once at session init; later
  requests re-verify credentials via a new non-side-effecting
  AuthService.verifyUserCredentials (no session/audit spam).
- Failed-login limiter (5/60s, keyed per-IP, per-IP+email, and per-email so IP
  rotation can't brute one account) since direct login bypasses the controller
  throttler. Only real credential failures count.
- MCP_TOKEN shared guard moved off Authorization to an X-MCP-Token header
  (timing-safe compare); credsConfigured 503 gate replaced by a clear 401.
- No secrets logged; all auth resolved before res.hijack() so failures return
  clean 401 JSON. .env.example marks the service account optional.

Implements docs/backlog/mcp-per-user-auth.md (variant L).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 07:19:31 +03:00
parent c8af637654
commit 4a00dfc3b2
10 changed files with 1436 additions and 83 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ import { TokenModule } from './token.module';
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService],
exports: [SignupService, AuthService],
})
export class AuthModule {}
@@ -57,7 +57,23 @@ export class AuthService {
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
/**
* Verify a user's email + password WITHOUT any side effects: it performs the
* exact same user lookup, password comparison, email-verified and disabled
* checks as `login()`, but does NOT mint a session/token, does NOT write the
* USER_LOGIN audit event, and does NOT update lastLoginAt. Returns the matched
* user on success; throws UnauthorizedException (credentials) or whatever
* `throwIfEmailNotVerified` throws otherwise.
*
* Use this for repeated per-request credential re-validation (e.g. the /mcp
* anti-fixation check on subsequent requests) where minting a new DB session
* and audit row on every call would be audit spam / a session-table DoS. The
* full `login()` reuses it so there is no behaviour drift between the two.
*/
async verifyUserCredentials(
loginDto: LoginDto,
workspaceId: string,
): Promise<User> {
const user = await this.userRepo.findByEmail(loginDto.email, workspaceId, {
includePassword: true,
});
@@ -84,6 +100,12 @@ export class AuthService {
appSecret: this.environmentService.getAppSecret(),
});
return user;
}
async login(loginDto: LoginDto, workspaceId: string) {
const user = await this.verifyUserCredentials(loginDto, workspaceId);
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);