Skip to content

Commit a495516

Browse files
authored
feat(copilot): enable azure openai and move key validation (#1134)
* Copilot enterprise * Fix validation and enterprise azure keys * Lint * update tests * Update * Lint * Remove hardcoded ishosted * Lint
1 parent 1f9b4a8 commit a495516

File tree

12 files changed

+5992
-150
lines changed

12 files changed

+5992
-150
lines changed
Lines changed: 23 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
1-
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
21
import { type NextRequest, NextResponse } from 'next/server'
32
import { getSession } from '@/lib/auth'
43
import { env } from '@/lib/env'
54
import { createLogger } from '@/lib/logs/console/logger'
6-
import { generateApiKey } from '@/lib/utils'
7-
import { db } from '@/db'
8-
import { copilotApiKeys } from '@/db/schema'
5+
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
96

107
const logger = createLogger('CopilotApiKeysGenerate')
118

12-
function deriveKey(keyString: string): Buffer {
13-
return createHash('sha256').update(keyString, 'utf8').digest()
14-
}
15-
16-
function encryptRandomIv(plaintext: string, keyString: string): string {
17-
const key = deriveKey(keyString)
18-
const iv = randomBytes(16)
19-
const cipher = createCipheriv('aes-256-gcm', key, iv)
20-
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
21-
encrypted += cipher.final('hex')
22-
const authTag = cipher.getAuthTag().toString('hex')
23-
return `${iv.toString('hex')}:${encrypted}:${authTag}`
24-
}
25-
26-
function computeLookup(plaintext: string, keyString: string): string {
27-
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
28-
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
29-
.update(plaintext, 'utf8')
30-
.digest('hex')
31-
}
9+
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
3210

3311
export async function POST(req: NextRequest) {
3412
try {
@@ -37,34 +15,36 @@ export async function POST(req: NextRequest) {
3715
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3816
}
3917

40-
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
41-
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
42-
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
43-
}
44-
4518
const userId = session.user.id
4619

47-
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
48-
const rawKey = generateApiKey().replace(/^sim_/, '')
49-
const plaintextKey = `sk-sim-copilot-${rawKey}`
50-
51-
// Encrypt with random IV for confidentiality
52-
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
20+
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify({ userId }),
24+
})
25+
26+
if (!res.ok) {
27+
const errorBody = await res.text().catch(() => '')
28+
logger.error('Sim Agent generate key error', { status: res.status, error: errorBody })
29+
return NextResponse.json(
30+
{ error: 'Failed to generate copilot API key' },
31+
{ status: res.status || 500 }
32+
)
33+
}
5334

54-
// Compute deterministic lookup value for O(1) search
55-
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
35+
const data = (await res.json().catch(() => null)) as { apiKey?: string } | null
5636

57-
const [inserted] = await db
58-
.insert(copilotApiKeys)
59-
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
60-
.returning({ id: copilotApiKeys.id })
37+
if (!data?.apiKey) {
38+
logger.error('Sim Agent generate key returned invalid payload')
39+
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
40+
}
6141

6242
return NextResponse.json(
63-
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
43+
{ success: true, key: { id: 'new', apiKey: data.apiKey } },
6444
{ status: 201 }
6545
)
6646
} catch (error) {
67-
logger.error('Failed to generate copilot API key', { error })
47+
logger.error('Failed to proxy generate copilot API key', { error })
6848
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
6949
}
7050
}

apps/sim/app/api/copilot/api-keys/route.ts

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,12 @@
1-
import { createDecipheriv, createHash } from 'crypto'
2-
import { and, eq } from 'drizzle-orm'
31
import { type NextRequest, NextResponse } from 'next/server'
42
import { getSession } from '@/lib/auth'
53
import { env } from '@/lib/env'
64
import { createLogger } from '@/lib/logs/console/logger'
7-
import { db } from '@/db'
8-
import { copilotApiKeys } from '@/db/schema'
5+
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
96

107
const logger = createLogger('CopilotApiKeys')
118

12-
function deriveKey(keyString: string): Buffer {
13-
return createHash('sha256').update(keyString, 'utf8').digest()
14-
}
15-
16-
function decryptWithKey(encryptedValue: string, keyString: string): string {
17-
const parts = encryptedValue.split(':')
18-
if (parts.length !== 3) {
19-
throw new Error('Invalid encrypted value format')
20-
}
21-
const [ivHex, encryptedHex, authTagHex] = parts
22-
const key = deriveKey(keyString)
23-
const iv = Buffer.from(ivHex, 'hex')
24-
const decipher = createDecipheriv('aes-256-gcm', key, iv)
25-
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
26-
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
27-
decrypted += decipher.final('utf8')
28-
return decrypted
29-
}
9+
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
3010

3111
export async function GET(request: NextRequest) {
3212
try {
@@ -35,22 +15,28 @@ export async function GET(request: NextRequest) {
3515
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3616
}
3717

38-
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
39-
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
40-
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
18+
const userId = session.user.id
19+
20+
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify({ userId }),
24+
})
25+
26+
if (!res.ok) {
27+
const errorBody = await res.text().catch(() => '')
28+
logger.error('Sim Agent get-api-keys error', { status: res.status, error: errorBody })
29+
return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 })
4130
}
4231

43-
const userId = session.user.id
32+
const apiKeys = (await res.json().catch(() => null)) as { id: string; apiKey: string }[] | null
4433

45-
const rows = await db
46-
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
47-
.from(copilotApiKeys)
48-
.where(eq(copilotApiKeys.userId, userId))
34+
if (!Array.isArray(apiKeys)) {
35+
logger.error('Sim Agent get-api-keys returned invalid payload')
36+
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
37+
}
4938

50-
const keys = rows.map((row) => ({
51-
id: row.id,
52-
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
53-
}))
39+
const keys = apiKeys
5440

5541
return NextResponse.json({ keys }, { status: 200 })
5642
} catch (error) {
@@ -73,9 +59,23 @@ export async function DELETE(request: NextRequest) {
7359
return NextResponse.json({ error: 'id is required' }, { status: 400 })
7460
}
7561

76-
await db
77-
.delete(copilotApiKeys)
78-
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
62+
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/delete`, {
63+
method: 'POST',
64+
headers: { 'Content-Type': 'application/json' },
65+
body: JSON.stringify({ userId, apiKeyId: id }),
66+
})
67+
68+
if (!res.ok) {
69+
const errorBody = await res.text().catch(() => '')
70+
logger.error('Sim Agent delete key error', { status: res.status, error: errorBody })
71+
return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 })
72+
}
73+
74+
const data = (await res.json().catch(() => null)) as { success?: boolean } | null
75+
if (!data?.success) {
76+
logger.error('Sim Agent delete key returned invalid payload')
77+
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
78+
}
7979

8080
return NextResponse.json({ success: true }, { status: 200 })
8181
} catch (error) {
Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,29 @@
1-
import { createHmac } from 'crypto'
21
import { eq } from 'drizzle-orm'
32
import { type NextRequest, NextResponse } from 'next/server'
4-
import { env } from '@/lib/env'
3+
import { checkInternalApiKey } from '@/lib/copilot/utils'
54
import { createLogger } from '@/lib/logs/console/logger'
65
import { db } from '@/db'
7-
import { copilotApiKeys, userStats } from '@/db/schema'
6+
import { userStats } from '@/db/schema'
87

98
const logger = createLogger('CopilotApiKeysValidate')
109

11-
function computeLookup(plaintext: string, keyString: string): string {
12-
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
13-
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
14-
.update(plaintext, 'utf8')
15-
.digest('hex')
16-
}
17-
1810
export async function POST(req: NextRequest) {
1911
try {
20-
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
21-
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
22-
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
23-
}
24-
25-
const body = await req.json().catch(() => null)
26-
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
27-
28-
if (!apiKey) {
12+
// Authenticate via internal API key header
13+
const auth = checkInternalApiKey(req)
14+
if (!auth.success) {
2915
return new NextResponse(null, { status: 401 })
3016
}
3117

32-
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
33-
34-
// Find matching API key and its user
35-
const rows = await db
36-
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
37-
.from(copilotApiKeys)
38-
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
39-
.limit(1)
18+
const body = await req.json().catch(() => null)
19+
const userId = typeof body?.userId === 'string' ? body.userId : undefined
4020

41-
if (rows.length === 0) {
42-
return new NextResponse(null, { status: 401 })
21+
if (!userId) {
22+
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
4323
}
4424

45-
const { userId } = rows[0]
25+
logger.info('[API VALIDATION] Validating usage limit', { userId })
4626

47-
// Check usage for the associated user
4827
const usage = await db
4928
.select({
5029
currentPeriodCost: userStats.currentPeriodCost,
@@ -55,6 +34,8 @@ export async function POST(req: NextRequest) {
5534
.where(eq(userStats.userId, userId))
5635
.limit(1)
5736

37+
logger.info('[API VALIDATION] Usage limit validated', { userId, usage })
38+
5839
if (usage.length > 0) {
5940
const currentUsage = Number.parseFloat(
6041
(usage[0].currentPeriodCost?.toString() as string) ||
@@ -64,16 +45,14 @@ export async function POST(req: NextRequest) {
6445
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
6546

6647
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
67-
// Usage exceeded
6848
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
6949
return new NextResponse(null, { status: 402 })
7050
}
7151
}
7252

73-
// Valid and within usage limits
7453
return new NextResponse(null, { status: 200 })
7554
} catch (error) {
76-
logger.error('Error validating copilot API key', { error })
77-
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
55+
logger.error('Error validating usage limit', { error })
56+
return NextResponse.json({ error: 'Failed to validate usage' }, { status: 500 })
7857
}
7958
}

apps/sim/app/api/copilot/chat/route.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ describe('Copilot Chat API Route', () => {
224224
stream: true,
225225
streamToolCalls: true,
226226
mode: 'agent',
227-
provider: 'openai',
227+
provider: {
228+
provider: 'anthropic',
229+
model: 'claude-3-haiku-20240307',
230+
apiKey: 'test-sim-agent-key',
231+
},
228232
depth: 0,
229233
origin: 'http://localhost:3000',
230234
}),
@@ -288,7 +292,11 @@ describe('Copilot Chat API Route', () => {
288292
stream: true,
289293
streamToolCalls: true,
290294
mode: 'agent',
291-
provider: 'openai',
295+
provider: {
296+
provider: 'anthropic',
297+
model: 'claude-3-haiku-20240307',
298+
apiKey: 'test-sim-agent-key',
299+
},
292300
depth: 0,
293301
origin: 'http://localhost:3000',
294302
}),
@@ -344,7 +352,11 @@ describe('Copilot Chat API Route', () => {
344352
stream: true,
345353
streamToolCalls: true,
346354
mode: 'agent',
347-
provider: 'openai',
355+
provider: {
356+
provider: 'anthropic',
357+
model: 'claude-3-haiku-20240307',
358+
apiKey: 'test-sim-agent-key',
359+
},
348360
depth: 0,
349361
origin: 'http://localhost:3000',
350362
}),
@@ -440,7 +452,11 @@ describe('Copilot Chat API Route', () => {
440452
stream: true,
441453
streamToolCalls: true,
442454
mode: 'ask',
443-
provider: 'openai',
455+
provider: {
456+
provider: 'anthropic',
457+
model: 'claude-3-haiku-20240307',
458+
apiKey: 'test-sim-agent-key',
459+
},
444460
depth: 0,
445461
origin: 'http://localhost:3000',
446462
}),

apps/sim/app/api/copilot/chat/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/lib/copilot/auth'
1313
import { getCopilotModel } from '@/lib/copilot/config'
1414
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
15+
import type { CopilotProviderConfig } from '@/lib/copilot/types'
1516
import { env } from '@/lib/env'
1617
import { createLogger } from '@/lib/logs/console/logger'
1718
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
@@ -399,8 +400,29 @@ export async function POST(req: NextRequest) {
399400
})
400401
}
401402

403+
const defaults = getCopilotModel('chat')
404+
const providerToUse = (env.COPILOT_PROVIDER as any) || defaults.provider
405+
const modelToUse = env.COPILOT_MODEL || defaults.model
406+
407+
let providerConfig: CopilotProviderConfig
408+
409+
if (providerToUse === 'azure-openai') {
410+
providerConfig = {
411+
provider: 'azure-openai',
412+
model: modelToUse,
413+
apiKey: env.AZURE_OPENAI_API_KEY,
414+
apiVersion: env.AZURE_OPENAI_API_VERSION,
415+
endpoint: env.AZURE_OPENAI_ENDPOINT,
416+
}
417+
} else {
418+
providerConfig = {
419+
provider: providerToUse,
420+
model: modelToUse,
421+
apiKey: env.COPILOT_API_KEY,
422+
}
423+
}
424+
402425
// Determine provider and conversationId to use for this request
403-
const providerToUse = provider || 'openai'
404426
const effectiveConversationId =
405427
(currentChat?.conversationId as string | undefined) || conversationId
406428

@@ -416,7 +438,7 @@ export async function POST(req: NextRequest) {
416438
stream: stream,
417439
streamToolCalls: true,
418440
mode: mode,
419-
provider: providerToUse,
441+
provider: providerConfig,
420442
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
421443
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
422444
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE "copilot_api_keys" CASCADE;

0 commit comments

Comments
 (0)