Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ model User {

// Agent relations
agents Agent[] @relation("OwnedAgents")

// MCP API keys (multiple keys per user)
mcpApiKeys McpApiKey[]
}

// Auth.js Account model for OAuth providers (future-proofing)
Expand Down Expand Up @@ -717,6 +720,21 @@ model Agent {
@@index([ownerId])
}

// MCP API keys - supports multiple named keys per user
model McpApiKey {
id String @id @default(cuid())
name String // User-chosen label (e.g., "Work laptop", "CI server")
keyHash String @unique // SHA-256 hash of the full API key
keyPrefix String // First 8 chars of key for display (e.g., "mcp_a1b2")
createdAt DateTime @default(now())
lastUsedAt DateTime? // Updated on successful authentication

userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
}

// Chat session for storing conversation history
model ChatSession {
id String @id @default(cuid())
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/integrations/git-hook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ async function authenticateApiKey() {
// Hash the incoming key - database stores hashed keys
const keyHash = hashMcpKey(apiKey)

// First, check the new McpApiKey table (PUNT-376: multiple keys per user)
const mcpApiKey = await db.mcpApiKey.findUnique({
where: { keyHash },
select: {
user: {
select: {
id: true,
name: true,
isActive: true,
},
},
},
})

if (mcpApiKey?.user?.isActive) {
return mcpApiKey.user
}

// Fallback: check legacy User.mcpApiKey field
const user = await db.user.findUnique({
where: { mcpApiKey: keyHash },
select: {
Expand Down
131 changes: 131 additions & 0 deletions src/app/api/me/mcp-keys/[keyId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { handleApiError, validationError } from '@/lib/api-utils'
import { requireAuth } from '@/lib/auth-helpers'
import { db } from '@/lib/db'
import { projectEvents } from '@/lib/events'
import { verifyPassword } from '@/lib/password'
import {
decryptTotpSecret,
markRecoveryCodeUsed,
verifyRecoveryCode,
verifyTotpToken,
} from '@/lib/totp'

const revokeSchema = z.object({
password: z.string().min(1, 'Password is required'),
totpCode: z.string().optional(),
isRecoveryCode: z.boolean().optional(),
})

/**
* Verify user re-authentication (password + optional 2FA/recovery code)
*/
async function verifyReauth(
userId: string,
password: string,
totpCode?: string,
isRecoveryCode?: boolean,
): Promise<NextResponse | null> {
const user = await db.user.findUnique({
where: { id: userId },
select: {
passwordHash: true,
totpEnabled: true,
totpSecret: true,
totpRecoveryCodes: true,
},
})

if (!user?.passwordHash) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}

const isValidPassword = await verifyPassword(password, user.passwordHash)
if (!isValidPassword) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
}

if (user.totpEnabled && user.totpSecret) {
if (!totpCode) {
return NextResponse.json({ error: '2FA code required', requires2fa: true }, { status: 401 })
}

if (isRecoveryCode) {
if (!user.totpRecoveryCodes) {
return NextResponse.json({ error: 'No recovery codes available' }, { status: 401 })
}

const codeIndex = await verifyRecoveryCode(totpCode, user.totpRecoveryCodes)
if (codeIndex === -1) {
return NextResponse.json({ error: 'Invalid recovery code' }, { status: 401 })
}

const updatedCodes = markRecoveryCodeUsed(user.totpRecoveryCodes, codeIndex)
await db.user.update({
where: { id: userId },
data: { totpRecoveryCodes: updatedCodes },
})
} else {
const secret = decryptTotpSecret(user.totpSecret)
const isValidTotp = verifyTotpToken(totpCode, secret)
if (!isValidTotp) {
return NextResponse.json({ error: 'Invalid 2FA code' }, { status: 401 })
}
}
}

return null
}

/**
* DELETE /api/me/mcp-keys/[keyId] - Revoke a specific MCP API key
* Requires password + 2FA (if enabled) for security
*/
export async function DELETE(request: Request, { params }: { params: Promise<{ keyId: string }> }) {
try {
const currentUser = await requireAuth()
const { keyId } = await params

// Verify the key belongs to the current user
const key = await db.mcpApiKey.findUnique({
where: { id: keyId },
select: { userId: true },
})

if (!key || key.userId !== currentUser.id) {
return NextResponse.json({ error: 'Key not found' }, { status: 404 })
}

const body = await request.json()
const parsed = revokeSchema.safeParse(body)

if (!parsed.success) {
return validationError(parsed)
}

const { password, totpCode, isRecoveryCode } = parsed.data

// Verify re-authentication
const authError = await verifyReauth(currentUser.id, password, totpCode, isRecoveryCode)
if (authError) return authError

// Delete the key
await db.mcpApiKey.delete({
where: { id: keyId },
})

// Notify other tabs/browsers via SSE
projectEvents.emitUserEvent({
type: 'user.updated',
userId: currentUser.id,
tabId: request.headers.get('x-tab-id') || undefined,
timestamp: Date.now(),
changes: { mcpKeyUpdated: true },
})

return NextResponse.json({ success: true })
} catch (error) {
return handleApiError(error, 'revoke MCP key')
}
}
193 changes: 193 additions & 0 deletions src/app/api/me/mcp-keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { createHash, randomBytes } from 'node:crypto'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { handleApiError, validationError } from '@/lib/api-utils'
import { requireAuth } from '@/lib/auth-helpers'
import { db } from '@/lib/db'
import { projectEvents } from '@/lib/events'
import { verifyPassword } from '@/lib/password'
import {
decryptTotpSecret,
markRecoveryCodeUsed,
verifyRecoveryCode,
verifyTotpToken,
} from '@/lib/totp'

/**
* Hash an MCP API key using SHA-256
* SHA-256 is appropriate for high-entropy API keys (256 bits of randomness)
*/
function hashMcpKey(apiKey: string): string {
return createHash('sha256').update(apiKey).digest('hex')
}

const createKeySchema = z.object({
name: z
.string()
.min(1, 'Key name is required')
.max(100, 'Key name must be 100 characters or less')
.trim(),
password: z.string().min(1, 'Password is required'),
totpCode: z.string().optional(),
isRecoveryCode: z.boolean().optional(),
})

/**
* Verify user re-authentication (password + optional 2FA/recovery code)
* Returns error response if verification fails, null if successful
*/
async function verifyReauth(
userId: string,
password: string,
totpCode?: string,
isRecoveryCode?: boolean,
): Promise<NextResponse | null> {
const user = await db.user.findUnique({
where: { id: userId },
select: {
passwordHash: true,
totpEnabled: true,
totpSecret: true,
totpRecoveryCodes: true,
},
})

if (!user?.passwordHash) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}

const isValidPassword = await verifyPassword(password, user.passwordHash)
if (!isValidPassword) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
}

if (user.totpEnabled && user.totpSecret) {
if (!totpCode) {
return NextResponse.json({ error: '2FA code required', requires2fa: true }, { status: 401 })
}

if (isRecoveryCode) {
if (!user.totpRecoveryCodes) {
return NextResponse.json({ error: 'No recovery codes available' }, { status: 401 })
}

const codeIndex = await verifyRecoveryCode(totpCode, user.totpRecoveryCodes)
if (codeIndex === -1) {
return NextResponse.json({ error: 'Invalid recovery code' }, { status: 401 })
}

const updatedCodes = markRecoveryCodeUsed(user.totpRecoveryCodes, codeIndex)
await db.user.update({
where: { id: userId },
data: { totpRecoveryCodes: updatedCodes },
})
} else {
const secret = decryptTotpSecret(user.totpSecret)
const isValidTotp = verifyTotpToken(totpCode, secret)
if (!isValidTotp) {
return NextResponse.json({ error: 'Invalid 2FA code' }, { status: 401 })
}
}
}

return null
}

/**
* GET /api/me/mcp-keys - List all MCP API keys for the current user
* Returns prefix, name, dates only - never the full key or hash
*/
export async function GET() {
try {
const currentUser = await requireAuth()

const keys = await db.mcpApiKey.findMany({
where: { userId: currentUser.id },
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: 'desc' },
})

return NextResponse.json(keys)
} catch (error) {
return handleApiError(error, 'list MCP keys')
}
}

/**
* POST /api/me/mcp-keys - Create a new named MCP API key
* Returns the full key ONCE on creation - user must save it
*/
export async function POST(request: Request) {
try {
const currentUser = await requireAuth()

const body = await request.json()
const parsed = createKeySchema.safeParse(body)

if (!parsed.success) {
return validationError(parsed)
}

const { name, password, totpCode, isRecoveryCode } = parsed.data

// Verify re-authentication
const authError = await verifyReauth(currentUser.id, password, totpCode, isRecoveryCode)
if (authError) return authError

// Limit number of keys per user (reasonable cap)
const existingCount = await db.mcpApiKey.count({
where: { userId: currentUser.id },
})
if (existingCount >= 25) {
return NextResponse.json(
{ error: 'Maximum of 25 API keys per user. Please revoke unused keys.' },
{ status: 400 },
)
}

// Generate a secure random API key: mcp_ prefix + 64 hex chars
const apiKey = `mcp_${randomBytes(32).toString('hex')}`
const keyHash = hashMcpKey(apiKey)
const keyPrefix = apiKey.slice(0, 12) // "mcp_" + first 8 hex chars

const newKey = await db.mcpApiKey.create({
data: {
name,
keyHash,
keyPrefix,
userId: currentUser.id,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
lastUsedAt: true,
},
})

// Notify other tabs/browsers via SSE
projectEvents.emitUserEvent({
type: 'user.updated',
userId: currentUser.id,
tabId: request.headers.get('x-tab-id') || undefined,
timestamp: Date.now(),
changes: { mcpKeyUpdated: true },
})

// Return the full key only on creation
return NextResponse.json({
...newKey,
apiKey,
message: 'Save this key - it will not be shown again',
})
} catch (error) {
return handleApiError(error, 'create MCP key')
}
}
Loading
Loading