mcp key
This commit is contained in:
@@ -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() }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user