From a271da4dcc6048b4ec28b75c5758ea202f118748 Mon Sep 17 00:00:00 2001 From: jmynes <15176546+jmynes@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:35:13 -0500 Subject: [PATCH] feat(mcp): support multiple API keys per user (PUNT-376) Add McpApiKey model to support multiple named API keys per user, replacing the single-key approach. Legacy User.mcpApiKey field still works for backwards compatibility. - New Prisma model: McpApiKey (id, name, keyHash, keyPrefix, lastUsedAt) - New API routes: POST/GET /api/me/mcp-keys, DELETE /api/me/mcp-keys/[keyId] - Updated MCP auth (auth-helpers + git-hook) to check new table first, then legacy - Updated Profile UI with key list, create dialog, revoke, and new key display - Updated database export/import to include McpApiKey records Co-Authored-By: Claude Opus 4.6 (1M context) --- prisma/schema.prisma | 18 + src/app/api/integrations/git-hook/route.ts | 19 + src/app/api/me/mcp-keys/[keyId]/route.ts | 131 ++++ src/app/api/me/mcp-keys/route.ts | 193 ++++++ src/components/profile/mcp-tab.tsx | 583 ++++++++++++------ .../database-backup-comprehensiveness.test.ts | 1 + src/lib/auth-helpers.ts | 82 ++- src/lib/database-export.ts | 9 + src/lib/database-import.ts | 24 + src/lib/schemas/database-export.ts | 11 + 10 files changed, 870 insertions(+), 201 deletions(-) create mode 100644 src/app/api/me/mcp-keys/[keyId]/route.ts create mode 100644 src/app/api/me/mcp-keys/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 991cffa8..b19bbb40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) @@ -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()) diff --git a/src/app/api/integrations/git-hook/route.ts b/src/app/api/integrations/git-hook/route.ts index 13ec0ebd..6dbbd29b 100644 --- a/src/app/api/integrations/git-hook/route.ts +++ b/src/app/api/integrations/git-hook/route.ts @@ -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: { diff --git a/src/app/api/me/mcp-keys/[keyId]/route.ts b/src/app/api/me/mcp-keys/[keyId]/route.ts new file mode 100644 index 00000000..907b2973 --- /dev/null +++ b/src/app/api/me/mcp-keys/[keyId]/route.ts @@ -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 { + 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') + } +} diff --git a/src/app/api/me/mcp-keys/route.ts b/src/app/api/me/mcp-keys/route.ts new file mode 100644 index 00000000..966858a5 --- /dev/null +++ b/src/app/api/me/mcp-keys/route.ts @@ -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 { + 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') + } +} diff --git a/src/components/profile/mcp-tab.tsx b/src/components/profile/mcp-tab.tsx index f78171b5..df77ba86 100644 --- a/src/components/profile/mcp-tab.tsx +++ b/src/components/profile/mcp-tab.tsx @@ -1,11 +1,23 @@ 'use client' -import { useQueryClient } from '@tanstack/react-query' -import { Check, Copy, Eye, EyeOff, FileText, KeyRound, Terminal, Trash2 } from 'lucide-react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { formatDistanceToNow } from 'date-fns' +import { Check, Copy, Eye, EyeOff, FileText, KeyRound, Plus, Terminal, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' -import { Button } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button, LoadingButton } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { CodeBlock } from '@/components/ui/code-block' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { apiFetch, basePath } from '@/lib/base-path' import { showToast } from '@/lib/toast' @@ -16,6 +28,14 @@ interface MCPTabProps { isDemo: boolean } +interface McpApiKeyData { + id: string + name: string + keyPrefix: string + createdAt: string + lastUsedAt: string | null +} + /** * Displays a file path as a titlebar with copy button */ @@ -57,25 +77,56 @@ function PathDisplay({ path, onCopy }: { path: string; onCopy?: () => void }) { ) } -export function MCPTab({ isDemo }: MCPTabProps) { - const queryClient = useQueryClient() - const [mcpKeyLoading, setMcpKeyLoading] = useState(false) - const [mcpHasKey, setMcpHasKey] = useState(false) - const [mcpKeyHint, setMcpKeyHint] = useState(null) - const [mcpNewKey, setMcpNewKey] = useState(null) - const [mcpKeyVisible, setMcpKeyVisible] = useState(false) - const [mcpKeyFetched, setMcpKeyFetched] = useState(false) - const [mcpKeyCopied, setMcpKeyCopied] = useState(false) - const copyTimeoutRef = useRef | null>(null) +/** + * Row for a single MCP API key + */ +function McpKeyRow({ + apiKey, + onRevoke, +}: { + apiKey: McpApiKeyData + onRevoke: (keyId: string) => void +}) { + return ( +
+
+
+ + {apiKey.name} + {apiKey.keyPrefix}... +
+
- // Defer Radix Tabs to client to avoid hydration ID mismatch - const [mounted, setMounted] = useState(false) - useEffect(() => setMounted(true), []) +
+
+ {apiKey.lastUsedAt + ? formatDistanceToNow(new Date(apiKey.lastUsedAt), { addSuffix: true }) + : 'Never used'} +
+
+ {formatDistanceToNow(new Date(apiKey.createdAt), { addSuffix: true })} +
+ +
+
+ ) +} - // Reauth dialog state - const [showGenerateDialog, setShowGenerateDialog] = useState(false) - const [showRegenerateDialog, setShowRegenerateDialog] = useState(false) - const [showRevokeDialog, setShowRevokeDialog] = useState(false) +/** + * Newly created key display with copy functionality + */ +function NewKeyDisplay({ apiKey, onDismiss }: { apiKey: string; onDismiss: () => void }) { + const [visible, setVisible] = useState(true) + const [copied, setCopied] = useState(false) + const copyTimeoutRef = useRef | null>(null) useEffect(() => { return () => { @@ -83,89 +134,290 @@ export function MCPTab({ isDemo }: MCPTabProps) { } }, []) - const fetchMcpKeyStatus = useCallback(async () => { - if (isDemo) return + const handleCopy = useCallback(async () => { try { - const res = await apiFetch('/api/me/mcp-key') - if (res.ok) { - const data = (await res.json()) as { hasKey: boolean; keyHint: string | null } - setMcpHasKey(data.hasKey) - setMcpKeyHint(data.keyHint) - setMcpKeyFetched(true) - } + await navigator.clipboard.writeText(apiKey) + setCopied(true) + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current) + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000) + showToast.success('API key copied to clipboard') } catch { - // Silently fail + showToast.error('Failed to copy to clipboard') } - }, [isDemo]) + }, [apiKey]) - // Fetch on mount + return ( +
+
+ +

Copy this key now. It will not be shown again.

+
+
+ + {visible ? apiKey : '\u2022'.repeat(40)} + + + +
+ +
+ ) +} + +/** + * Two-step dialog for creating a new API key: + * Step 1: Enter key name + * Step 2: Re-authenticate with password (+ 2FA if enabled) + */ +function CreateKeyDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: ( + name: string, + password: string, + totpCode?: string, + isRecoveryCode?: boolean, + ) => Promise +}) { + const [step, setStep] = useState<'name' | 'auth'>('name') + const [keyName, setKeyName] = useState('') + const [nameError, setNameError] = useState(null) + const [loading, setLoading] = useState(false) + + // Reset when dialog closes useEffect(() => { - if (!mcpKeyFetched) fetchMcpKeyStatus() - }, [mcpKeyFetched, fetchMcpKeyStatus]) + if (!open) { + setStep('name') + setKeyName('') + setNameError(null) + setLoading(false) + } + }, [open]) + + const handleNameSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = keyName.trim() + if (!trimmed) { + setNameError('Key name is required') + return + } + if (trimmed.length > 100) { + setNameError('Key name must be 100 characters or less') + return + } + setNameError(null) + setStep('auth') + } - // Refetch when SSE notifies of key change (covers CLI, UI, and cross-tab updates) + if (step === 'auth') { + return ( + { + if (!o) { + onOpenChange(false) + } + }} + title={`Create key "${keyName.trim()}"`} + description="Enter your password to create the API key." + actionLabel="Create Key" + onConfirm={async (password, totpCode, isRecoveryCode) => { + await onConfirm(keyName.trim(), password, totpCode, isRecoveryCode) + }} + /> + ) + } + + return ( + + { + e.preventDefault() + setTimeout(() => { + document.getElementById('create-key-name')?.focus() + }, 0) + }} + > +
+ + Create MCP API Key + + Give this key a descriptive name so you can identify it later. + + + +
+
+ + { + setKeyName(e.target.value) + setNameError(null) + }} + className="bg-zinc-800 border-zinc-700 text-zinc-100" + placeholder='e.g., "Work laptop", "CI server"' + maxLength={100} + autoFocus + required + /> + {nameError &&

{nameError}

} +
+
+ + + + Cancel + + + Next + + +
+
+
+ ) +} + +export function MCPTab({ isDemo }: MCPTabProps) { + const queryClient = useQueryClient() + const [mcpKeyLoading, setMcpKeyLoading] = useState(false) + const [mcpNewKey, setMcpNewKey] = useState(null) + + // Defer Radix Tabs to client to avoid hydration ID mismatch + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + // Dialog state + const [showCreateDialog, setShowCreateDialog] = useState(false) + const [revokeKeyId, setRevokeKeyId] = useState(null) + + // Fetch keys from new multi-key endpoint + const { + data: mcpKeys, + isLoading: keysLoading, + refetch: refetchKeys, + } = useQuery({ + queryKey: ['mcp-keys', 'me'], + queryFn: async () => { + if (isDemo) return [] + const res = await apiFetch('/api/me/mcp-keys') + if (!res.ok) throw new Error('Failed to fetch MCP keys') + return res.json() + }, + enabled: !isDemo, + }) + + // Also check legacy key status for backwards compatibility display + const { data: legacyKeyStatus } = useQuery<{ hasKey: boolean; keyHint: string | null }>({ + queryKey: ['mcp-key-legacy'], + queryFn: async () => { + if (isDemo) return { hasKey: false, keyHint: null } + const res = await apiFetch('/api/me/mcp-key') + if (!res.ok) return { hasKey: false, keyHint: null } + return res.json() + }, + enabled: !isDemo, + }) + + // Refetch when SSE notifies of key change useEffect(() => { - const onKeyUpdated = () => fetchMcpKeyStatus() + const onKeyUpdated = () => { + refetchKeys() + queryClient.invalidateQueries({ queryKey: ['mcp-key-legacy'] }) + } window.addEventListener('punt:mcp-key-updated', onKeyUpdated) return () => window.removeEventListener('punt:mcp-key-updated', onKeyUpdated) - }, [fetchMcpKeyStatus]) + }, [refetchKeys, queryClient]) - const handleGenerateMcpKey = async ( - password?: string, + const handleCreateKey = async ( + name: string, + password: string, totpCode?: string, isRecoveryCode?: boolean, ) => { setMcpKeyLoading(true) try { - const res = await apiFetch('/api/me/mcp-key', { + const res = await apiFetch('/api/me/mcp-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password, totpCode, isRecoveryCode }), + body: JSON.stringify({ name, password, totpCode, isRecoveryCode }), }) const data = await res.json() if (!res.ok) { - // Check if 2FA is required if (data.requires2fa) { throw new Error('2FA code required') } - throw new Error(data.error || 'Failed to generate API key') + throw new Error(data.error || 'Failed to create API key') } setMcpNewKey(data.apiKey) - setMcpKeyVisible(true) - setMcpHasKey(true) - setMcpKeyHint(data.apiKey.slice(-4)) + setShowCreateDialog(false) + refetchKeys() queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'agents'] }) - showToast.success('MCP API key generated') + showToast.success('MCP API key created') } finally { setMcpKeyLoading(false) } } - const handleRevokeMcpKey = async ( - password: string, - totpCode?: string, - isRecoveryCode?: boolean, - ) => { + const handleRevokeKey = async (password: string, totpCode?: string, isRecoveryCode?: boolean) => { + if (!revokeKeyId) return setMcpKeyLoading(true) try { - const res = await apiFetch('/api/me/mcp-key', { + const res = await apiFetch(`/api/me/mcp-keys/${revokeKeyId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password, totpCode, isRecoveryCode }), }) const data = await res.json() if (!res.ok) { - // Check if 2FA is required if (data.requires2fa) { throw new Error('2FA code required') } throw new Error(data.error || 'Failed to revoke API key') } - setMcpHasKey(false) - setMcpKeyHint(null) - setMcpNewKey(null) - setMcpKeyVisible(false) + setRevokeKeyId(null) + refetchKeys() queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'agents'] }) showToast.success('MCP API key revoked') @@ -174,19 +426,6 @@ export function MCPTab({ isDemo }: MCPTabProps) { } } - const handleCopyMcpKey = useCallback(async () => { - if (!mcpNewKey) return - try { - await navigator.clipboard.writeText(mcpNewKey) - setMcpKeyCopied(true) - if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current) - copyTimeoutRef.current = setTimeout(() => setMcpKeyCopied(false), 2000) - showToast.success('API key copied to clipboard') - } catch { - showToast.error('Failed to copy to clipboard') - } - }, [mcpNewKey]) - if (isDemo) { return (
@@ -194,7 +433,7 @@ export function MCPTab({ isDemo }: MCPTabProps) {
- MCP API Key + MCP API Keys
API key management is not available in demo mode @@ -212,134 +451,106 @@ export function MCPTab({ isDemo }: MCPTabProps) { ) } + const hasKeys = (mcpKeys && mcpKeys.length > 0) ?? false + const hasLegacyKey = legacyKeyStatus?.hasKey ?? false + return (
-
- - MCP API Key +
+
+ + MCP API Keys +
+
- Generate an API key to connect Claude Code to PUNT via MCP + Create and manage API keys to connect Claude Code to PUNT via MCP. Each key can be named + for easy identification. - {mcpHasKey ? ( - <> - {mcpNewKey ? ( -
-
- -

- Copy this key now. It won't be shown again. -

-
-
- - {mcpKeyVisible ? mcpNewKey : '•'.repeat(40)} - - - -
+ {/* Newly created key display */} + {mcpNewKey && setMcpNewKey(null)} />} + + {/* Legacy key notice */} + {hasLegacyKey && ( +
+ +

+ You have a legacy API key + {legacyKeyStatus?.keyHint ? ( + <> + {' '} + ending in{' '} + ...{legacyKeyStatus.keyHint} + + ) : null} + . It still works, but consider creating named keys for better management. +

+
+ )} + + {/* Key list */} + {keysLoading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : hasKeys ? ( +
+ {/* Header */} +
+
+ Key
- ) : ( -
- -

- {mcpKeyHint ? ( - <> - Active key ending in{' '} - ...{mcpKeyHint} - - ) : ( - 'MCP API key is configured' - )} -

+
+
Last Used
+
Created
+
- )} - -
- - - -
- - ) : ( -
-

- Generate an API key to authenticate MCP requests from Claude Code. + {mcpKeys?.map((key) => ( + setRevokeKeyId(id)} /> + ))} +

+ ) : !hasLegacyKey ? ( +
+

+ No API keys yet. Create one to authenticate MCP requests from Claude Code.

- -
- )} + ) : null} + + {/* Create key dialog */} + + + {/* Revoke key dialog */} + { + if (!isOpen) setRevokeKeyId(null) + }} + title="Revoke MCP API Key?" + description="This will immediately invalidate the key. Any MCP clients using it will stop working." + actionLabel="Revoke Key" + actionVariant="destructive" + onConfirm={handleRevokeKey} + /> diff --git a/src/lib/__tests__/database-backup-comprehensiveness.test.ts b/src/lib/__tests__/database-backup-comprehensiveness.test.ts index 1c8657b7..6e159a31 100644 --- a/src/lib/__tests__/database-backup-comprehensiveness.test.ts +++ b/src/lib/__tests__/database-backup-comprehensiveness.test.ts @@ -235,6 +235,7 @@ describe('Database Backup Comprehensiveness', () => { expect(Array.isArray(data.attachments)).toBe(true) expect(Array.isArray(data.ticketSprintHistory)).toBe(true) expect(Array.isArray(data.invitations)).toBe(true) + expect(Array.isArray(data.mcpApiKeys)).toBe(true) }) it('should NOT export ephemeral/security-sensitive models', async () => { diff --git a/src/lib/auth-helpers.ts b/src/lib/auth-helpers.ts index 2ecdd82b..4f66289f 100644 --- a/src/lib/auth-helpers.ts +++ b/src/lib/auth-helpers.ts @@ -39,8 +39,8 @@ function hashMcpKey(apiKey: string): string { /** * Check if the current request is authenticated via MCP API key. + * Checks both the new McpApiKey table (PUNT-376) and the legacy User.mcpApiKey field. * Hashes the incoming key and looks up the user by the hash. - * Uses timing-safe comparison to prevent timing attacks on key enumeration. * Returns the user if found and active, null otherwise. */ async function getMcpUser() { @@ -61,25 +61,77 @@ async function getMcpUser() { // Hash the incoming key - this is deterministic so we can use it for lookup const keyHash = hashMcpKey(apiKey) - // Look up user by the exact hash - // Since SHA-256 produces unique hashes, this is effectively a constant-time - // comparison from the perspective of an attacker - the database either - // finds a match or doesn't, with no information leakage about partial matches - const user = await db.user.findUnique({ - where: { mcpApiKey: keyHash }, + // First, check the new McpApiKey table (PUNT-376: multiple keys per user) + const mcpApiKey = await db.mcpApiKey.findUnique({ + where: { keyHash }, select: { id: true, - username: true, - email: true, - name: true, - avatar: true, - isSystemAdmin: true, - isActive: true, + lastUsedAt: true, + user: { + select: { + id: true, + username: true, + email: true, + name: true, + avatar: true, + isSystemAdmin: true, + isActive: true, + }, + }, }, }) - // Only return active users - if (!user?.isActive) { + let user: { + id: string + username: string + email: string | null + name: string + avatar: string | null + isSystemAdmin: boolean + isActive: boolean + } | null = null + + if (mcpApiKey?.user?.isActive) { + user = mcpApiKey.user + + // Update lastUsedAt if more than 5 minutes since last update (fire-and-forget) + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000) + if (!mcpApiKey.lastUsedAt || mcpApiKey.lastUsedAt < fiveMinutesAgo) { + db.mcpApiKey + .update({ + where: { id: mcpApiKey.id }, + data: { lastUsedAt: new Date() }, + }) + .catch((err: unknown) => { + logger.error( + 'Failed to update McpApiKey.lastUsedAt', + err instanceof Error ? err : undefined, + ) + }) + } + } + + // Fallback: check legacy User.mcpApiKey field for backwards compatibility + if (!user) { + const legacyUser = await db.user.findUnique({ + where: { mcpApiKey: keyHash }, + select: { + id: true, + username: true, + email: true, + name: true, + avatar: true, + isSystemAdmin: true, + isActive: true, + }, + }) + + if (legacyUser?.isActive) { + user = legacyUser + } + } + + if (!user) { return null } diff --git a/src/lib/database-export.ts b/src/lib/database-export.ts index 3e4d8466..061dc312 100644 --- a/src/lib/database-export.ts +++ b/src/lib/database-export.ts @@ -84,6 +84,7 @@ export async function exportDatabase(options: ExportOptions = {}): Promise ({ + ...k, + createdAt: k.createdAt.toISOString(), + lastUsedAt: k.lastUsedAt?.toISOString() ?? null, + })), } as ExportData } diff --git a/src/lib/database-import.ts b/src/lib/database-import.ts index f8d02d11..fd6cbd96 100644 --- a/src/lib/database-import.ts +++ b/src/lib/database-import.ts @@ -394,6 +394,7 @@ async function wipeDatabase(tx: Parameters[0] await tx.ticketLink.deleteMany() await tx.ticket.deleteMany() await safeDeleteAll('Agent') + await safeDeleteAll('McpApiKey') await tx.projectSprintSettings.deleteMany() await tx.projectMember.deleteMany() await tx.sprint.deleteMany() @@ -961,6 +962,29 @@ export async function importDatabase( counts.agents = agents.length } } + + // Step 20: Import MCP API Keys (table may not exist in older database schemas) + const mcpApiKeys = dataToImport.mcpApiKeys ?? [] + if (mcpApiKeys.length > 0) { + const mcpApiKeyTableExists = await tx.$queryRaw<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'McpApiKey' + ) as exists + ` + if (mcpApiKeyTableExists[0]?.exists) { + for (const mcpApiKey of mcpApiKeys) { + await tx.mcpApiKey.create({ + data: { + ...mcpApiKey, + createdAt: new Date(mcpApiKey.createdAt), + lastUsedAt: mcpApiKey.lastUsedAt ? new Date(mcpApiKey.lastUsedAt) : null, + }, + select: { id: true }, + }) + } + } + } }, { timeout: 120_000, // 2 minute timeout for large imports diff --git a/src/lib/schemas/database-export.ts b/src/lib/schemas/database-export.ts index 3576caee..3457db2b 100644 --- a/src/lib/schemas/database-export.ts +++ b/src/lib/schemas/database-export.ts @@ -327,6 +327,16 @@ export const AgentSchema = z.object({ isActive: z.boolean(), }) +export const McpApiKeySchema = z.object({ + id: z.string(), + name: z.string(), + keyHash: z.string(), + keyPrefix: z.string(), + createdAt: z.string().datetime(), + lastUsedAt: nullableDate, + userId: z.string(), +}) + // ============================================================================ // Export data schema // ============================================================================ @@ -352,6 +362,7 @@ export const ExportDataSchema = z.object({ ticketSprintHistory: z.array(TicketSprintHistorySchema), invitations: z.array(InvitationSchema), agents: z.array(AgentSchema).optional().default([]), + mcpApiKeys: z.array(McpApiKeySchema).optional().default([]), }) export type ExportData = z.infer