This commit is contained in:
Bastian Wagner
2026-06-24 10:09:12 +02:00
parent 35613eddb6
commit fc61ef5ba9
15 changed files with 581 additions and 19 deletions

View File

@@ -6,6 +6,8 @@ export type AuditAction =
| 'user.login_failed'
| 'user.token_refreshed'
| 'user.onboarding_updated'
| 'user.mcp_api_key_created'
| 'user.mcp_api_key_revoked'
| 'template.created'
| 'template.created_from_list'
| 'template.updated'

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
@@ -55,6 +56,24 @@ export class AuthController {
return this.authService.getPublicUser(request.user!.sub);
}
@Get('me/mcp-api-key')
@UseGuards(JwtAuthGuard)
getMcpApiKeyStatus(@Req() request: AuthenticatedRequest) {
return this.authService.getMcpApiKeyStatus(request.user!.sub);
}
@Post('me/mcp-api-key')
@UseGuards(JwtAuthGuard)
createMcpApiKey(@Req() request: AuthenticatedRequest) {
return this.authService.createMcpApiKey(request.user!.sub);
}
@Delete('me/mcp-api-key')
@UseGuards(JwtAuthGuard)
revokeMcpApiKey(@Req() request: AuthenticatedRequest) {
return this.authService.revokeMcpApiKey(request.user!.sub);
}
@Get('users/search')
@UseGuards(JwtAuthGuard)
searchUsers(

View File

@@ -6,6 +6,7 @@ import { AuthController } from './auth.controller';
import { RefreshTokenEntity } from './refresh-token.entity';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { McpAuthGuard } from './mcp-auth.guard';
import { UserEntity } from './user.entity';
@Module({
@@ -15,7 +16,7 @@ import { UserEntity } from './user.entity';
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard],
providers: [AuthService, JwtAuthGuard, McpAuthGuard],
exports: [AuthService, JwtAuthGuard, McpAuthGuard],
})
export class AuthModule {}

View File

@@ -2,7 +2,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { JwtTokenPayload } from './auth.types';
import { AuthTokenResponse, JwtTokenPayload } from './auth.types';
import { AuthService } from './auth.service';
import { MailModule } from '../mail/mail.module';
import { MailService } from '../mail/mail.service';
@@ -18,7 +18,11 @@ describe('AuthService', () => {
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
imports: [
EventEmitterModule.forRoot(),
JwtModule.register({}),
MailModule,
],
providers: [
AuthService,
{
@@ -169,7 +173,9 @@ describe('AuthService', () => {
email: 'user@example.com',
password: 'password123',
});
const payload = await authService.verifyAccessToken(loginResponse.accessToken);
const payload = await authService.verifyAccessToken(
loginResponse.accessToken,
);
expect(payload.type).toBe('access');
expect(payload.email).toBe('user@example.com');
@@ -178,6 +184,56 @@ describe('AuthService', () => {
).rejects.toThrow('Access token is invalid.');
});
it('creates and validates a persistent MCP API key', async () => {
const loginResponse = await registerVerifiedUserAndLogin();
const apiKeyResponse = await authService.createMcpApiKey(
loginResponse.user.id,
);
const status = await authService.getMcpApiKeyStatus(loginResponse.user.id);
const payload = await authService.verifyMcpCredential(
apiKeyResponse.apiKey,
);
expect(apiKeyResponse.apiKey).toMatch(/^lfy_mcp_/);
expect(apiKeyResponse.createdAt).toBeDefined();
expect(status.createdAt).toBe(apiKeyResponse.createdAt);
expect(payload.type).toBe('mcp_api_key');
expect(payload.sub).toBe(loginResponse.user.id);
expect(payload.email).toBe('user@example.com');
});
it('rotates MCP API keys and rejects the old key', async () => {
const loginResponse = await registerVerifiedUserAndLogin();
const firstKey = await authService.createMcpApiKey(loginResponse.user.id);
const secondKey = await authService.createMcpApiKey(loginResponse.user.id);
expect(secondKey.apiKey).not.toBe(firstKey.apiKey);
await expect(authService.verifyMcpApiKey(firstKey.apiKey)).rejects.toThrow(
'MCP API key is invalid.',
);
await expect(
authService.verifyMcpApiKey(secondKey.apiKey),
).resolves.toMatchObject({
sub: loginResponse.user.id,
type: 'mcp_api_key',
});
});
it('revokes MCP API keys', async () => {
const loginResponse = await registerVerifiedUserAndLogin();
const apiKeyResponse = await authService.createMcpApiKey(
loginResponse.user.id,
);
const revokedStatus = await authService.revokeMcpApiKey(
loginResponse.user.id,
);
expect(revokedStatus.createdAt).toBeUndefined();
await expect(
authService.verifyMcpApiKey(apiKeyResponse.apiKey),
).rejects.toThrow('MCP API key is invalid.');
});
it('rejects duplicate registrations', async () => {
await authService.register({
email: 'user@example.com',
@@ -191,4 +247,20 @@ describe('AuthService', () => {
}),
).rejects.toThrow('Email is already registered.');
});
async function registerVerifiedUserAndLogin(): Promise<AuthTokenResponse> {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await authService.verifyEmail(token ?? undefined);
return authService.login({
email: 'user@example.com',
password: 'password123',
});
}
});

View File

@@ -8,7 +8,13 @@ import {
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
import {
createHash,
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
} from 'crypto';
import { Like, Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service';
import { LoginDto } from './dto/login.dto';
@@ -19,6 +25,8 @@ import {
AuthTokenResponse,
AuthTokens,
JwtTokenPayload,
McpApiKeyResponse,
McpApiKeyStatus,
PublicUser,
PublicUserSearchResult,
} from './auth.types';
@@ -30,6 +38,7 @@ import { UserEntity } from './user.entity';
export class AuthService {
private readonly accessTokenExpiresIn = '7d';
private readonly refreshTokenExpiresIn = '30d';
private readonly mcpApiKeyPrefix = 'lfy_mcp_';
private readonly accessTokenSecret =
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
private readonly refreshTokenSecret =
@@ -125,7 +134,7 @@ export class AuthService {
user: this.toPublicUser(savedUser),
};
} catch {
throw new BadRequestException('user not saved.')
throw new BadRequestException('user not saved.');
}
}
@@ -204,7 +213,9 @@ export class AuthService {
return response;
}
async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise<AuthTokenResponse> {
async refresh(
refreshTokenDto: RefreshTokenDto = {},
): Promise<AuthTokenResponse> {
const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken);
const payload = this.verifyRefreshToken(refreshToken);
const tokenRecord = await this.refreshTokensRepository.findOne({
@@ -270,6 +281,35 @@ export class AuthService {
}
}
async verifyMcpCredential(token: string): Promise<JwtTokenPayload> {
if (token.startsWith(this.mcpApiKeyPrefix)) {
return this.verifyMcpApiKey(token);
}
return this.verifyAccessToken(token);
}
async verifyMcpApiKey(apiKey: string): Promise<JwtTokenPayload> {
const apiKeyHash = this.hashApiKey(apiKey);
const user = await this.usersRepository.findOne({
where: { mcpApiKeyHash: apiKeyHash },
});
if (
!user ||
!user.verified ||
!this.apiKeyHashMatches(apiKey, apiKeyHash)
) {
throw new UnauthorizedException('MCP API key is invalid.');
}
return {
sub: user.id,
email: user.email,
type: 'mcp_api_key',
};
}
async getUserDisplayName(userId: string): Promise<string> {
const user = await this.usersRepository.findOne({
where: { id: userId },
@@ -294,6 +334,57 @@ export class AuthService {
return this.toPublicUser(user);
}
async getMcpApiKeyStatus(userId: string): Promise<McpApiKeyStatus> {
const user = await this.requireUser(userId);
return this.toMcpApiKeyStatus(user);
}
async createMcpApiKey(userId: string): Promise<McpApiKeyResponse> {
const user = await this.requireUser(userId);
const hadExistingKey = Boolean(user.mcpApiKeyHash);
const apiKey = `${this.mcpApiKeyPrefix}${randomBytes(32).toString('base64url')}`;
const createdAt = new Date();
user.mcpApiKeyHash = this.hashApiKey(apiKey);
user.mcpApiKeyCreatedAt = createdAt;
const savedUser = await this.usersRepository.save(user);
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.mcp_api_key_created',
entityType: 'user',
entityId: savedUser.id,
metadata: { rotated: hadExistingKey },
});
return {
apiKey,
...this.toMcpApiKeyStatus(savedUser),
};
}
async revokeMcpApiKey(userId: string): Promise<McpApiKeyStatus> {
const user = await this.requireUser(userId);
user.mcpApiKeyHash = null;
user.mcpApiKeyCreatedAt = null;
const savedUser = await this.usersRepository.save(user);
await this.auditLogService?.record({
actorUserId: savedUser.id,
actorEmail: savedUser.email,
action: 'user.mcp_api_key_revoked',
entityType: 'user',
entityId: savedUser.id,
});
return this.toMcpApiKeyStatus(savedUser);
}
async searchUsers(
actorUserId: string,
query?: string,
@@ -357,6 +448,18 @@ export class AuthService {
return this.toPublicUser(savedUser);
}
private async requireUser(userId: string): Promise<UserEntity> {
const user = await this.usersRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException('Authenticated user is required.');
}
return user;
}
private normalizeEmail(email?: string): string {
const normalizedEmail = email?.trim().toLowerCase();
@@ -477,6 +580,20 @@ export class AuthService {
return this.passwordMatches(token, tokenHash);
}
private hashApiKey(apiKey: string): string {
return createHash('sha256').update(apiKey).digest('hex');
}
private apiKeyHashMatches(apiKey: string, apiKeyHash: string): boolean {
const expectedHashBuffer = Buffer.from(apiKeyHash, 'hex');
const attemptedHashBuffer = Buffer.from(this.hashApiKey(apiKey), 'hex');
return (
expectedHashBuffer.length === attemptedHashBuffer.length &&
timingSafeEqual(expectedHashBuffer, attemptedHashBuffer)
);
}
private createToken(): string {
return randomBytes(32).toString('hex');
}
@@ -495,4 +612,10 @@ export class AuthService {
onboardingCompleted: user.onboardingCompleted === true,
};
}
private toMcpApiKeyStatus(user: UserEntity): McpApiKeyStatus {
return user.mcpApiKeyHash && user.mcpApiKeyCreatedAt
? { createdAt: user.mcpApiKeyCreatedAt.toISOString() }
: {};
}
}

View File

@@ -22,7 +22,7 @@ export interface AuthTokenResponse extends AuthTokens {
export interface JwtTokenPayload {
sub: string;
email: string;
type: 'access' | 'refresh';
type: 'access' | 'refresh' | 'mcp_api_key';
jti?: string;
}
@@ -43,3 +43,11 @@ export interface PublicUserSearchResult {
email: string;
name?: string;
}
export interface McpApiKeyStatus {
createdAt?: string;
}
export interface McpApiKeyResponse extends McpApiKeyStatus {
apiKey: string;
}

View File

@@ -0,0 +1,42 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticatedRequest } from './auth.types';
import { AuthService } from './auth.service';
@Injectable()
export class McpAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractBearerToken(request);
request.user = await this.authService.verifyMcpCredential(token);
return true;
}
private extractBearerToken(request: AuthenticatedRequest): string {
const authorizationHeader = request.headers.authorization;
if (!authorizationHeader) {
throw new UnauthorizedException(
'Authorization bearer token is required.',
);
}
const [scheme, token] = authorizationHeader.split(' ');
if (scheme.toLowerCase() !== 'bearer' || !token) {
throw new UnauthorizedException(
'Authorization bearer token is required.',
);
}
return token;
}
}

View File

@@ -38,6 +38,13 @@ export class UserEntity {
@Column({ type: 'boolean', default: false })
onboardingCompleted!: boolean;
@Index('IDX_users_mcp_api_key_hash', { unique: true })
@Column({ type: 'varchar', length: 64, nullable: true })
mcpApiKeyHash?: string | null;
@Column({ type: 'datetime', precision: 3, nullable: true })
mcpApiKeyCreatedAt?: Date | null;
@CreateDateColumn({
type: 'datetime',
precision: 3,

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface {
name = 'AddMcpApiKeyToUsers1781600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('users'))) {
return;
}
if (!(await queryRunner.hasColumn('users', 'mcpApiKeyHash'))) {
await queryRunner.query(
'ALTER TABLE `users` ADD `mcpApiKeyHash` varchar(64) NULL',
);
await queryRunner.query(
'CREATE UNIQUE INDEX `IDX_users_mcp_api_key_hash` ON `users` (`mcpApiKeyHash`)',
);
}
if (!(await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt'))) {
await queryRunner.query(
'ALTER TABLE `users` ADD `mcpApiKeyCreatedAt` datetime(3) NULL',
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('users'))) {
return;
}
if (await queryRunner.hasColumn('users', 'mcpApiKeyHash')) {
await queryRunner.query(
'DROP INDEX `IDX_users_mcp_api_key_hash` ON `users`',
);
await queryRunner.query('ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`');
}
if (await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt')) {
await queryRunner.query(
'ALTER TABLE `users` DROP COLUMN `mcpApiKeyCreatedAt`',
);
}
}
}

View File

@@ -14,7 +14,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { McpAuthGuard } from '../auth/mcp-auth.guard';
import { McpServerService } from './mcp-server.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -26,7 +26,7 @@ interface McpSession {
}
@Controller('mcp')
@UseGuards(JwtAuthGuard)
@UseGuards(McpAuthGuard)
export class McpController {
private readonly sessions = new Map<string, McpSession>();