Skip to content

Commit 9bea2fa

Browse files
committed
encrypt api keys
1 parent 0a78fde commit 9bea2fa

7 files changed

Lines changed: 317 additions & 37 deletions

File tree

apps/sim/app/api/users/me/api-keys/route.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getSession } from '@/lib/auth'
55
import { createLogger } from '@/lib/logs/console/logger'
6-
import { generateApiKey } from '@/lib/utils'
6+
import { createApiKey } from '@/lib/security/api-key-auth'
77
import { db } from '@/db'
88
import { apiKey } from '@/db/schema'
99

@@ -83,27 +83,33 @@ export async function POST(request: NextRequest) {
8383
)
8484
}
8585

86-
const keyValue = generateApiKey()
86+
// Create new API key with hashing
87+
const { key: plainKey, hashedKey } = await createApiKey(true)
8788

88-
// Insert the new API key
89+
// Store the hashed version in the database
8990
const [newKey] = await db
9091
.insert(apiKey)
9192
.values({
9293
id: nanoid(),
9394
userId,
9495
name,
95-
key: keyValue,
96+
key: hashedKey!, // Store the hashed version
9697
createdAt: new Date(),
9798
updatedAt: new Date(),
9899
})
99100
.returning({
100101
id: apiKey.id,
101102
name: apiKey.name,
102-
key: apiKey.key,
103103
createdAt: apiKey.createdAt,
104104
})
105105

106-
return NextResponse.json({ key: newKey })
106+
// Return the plain key to the user (they'll never see it again)
107+
return NextResponse.json({
108+
key: {
109+
...newKey,
110+
key: plainKey, // Return the plain text key for user to copy
111+
},
112+
})
107113
} catch (error) {
108114
logger.error('Failed to create API key', { error })
109115
return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 })

apps/sim/app/api/workflows/middleware.ts

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { and, eq } from 'drizzle-orm'
1+
import { eq } from 'drizzle-orm'
22
import type { NextRequest } from 'next/server'
33
import { createLogger } from '@/lib/logs/console/logger'
4+
import { authenticateApiKey, isHashedKey, migrateKeyToHashed } from '@/lib/security/api-key-auth'
45
import { getWorkflowById } from '@/lib/workflows/utils'
56
import { db } from '@/db'
67
import { apiKey, workspace, workspaceApiKey } from '@/db/schema'
@@ -70,36 +71,50 @@ export async function validateWorkflowAccess(
7071
// Check both personal API keys and workspace API keys
7172

7273
// First, check personal API keys belonging to the workflow owner
73-
const [personalKey] = await db
74-
.select({ key: apiKey.key })
74+
const personalKeys = await db
75+
.select({
76+
id: apiKey.id,
77+
key: apiKey.key,
78+
})
7579
.from(apiKey)
76-
.where(and(eq(apiKey.userId, workflow.userId), eq(apiKey.key, apiKeyHeader)))
77-
.limit(1)
80+
.where(eq(apiKey.userId, workflow.userId))
81+
82+
let validPersonalKey = null
83+
84+
// Check each personal key with authentication function
85+
for (const key of personalKeys) {
86+
const isValid = await authenticateApiKey(apiKeyHeader, key.key)
87+
if (isValid) {
88+
validPersonalKey = key
89+
break
90+
}
91+
}
7892

7993
// If not found in personal keys, check workspace API keys
80-
let workspaceKey = null
81-
if (!personalKey) {
82-
const [wsKey] = await db
94+
let validWorkspaceKey = null
95+
if (!validPersonalKey) {
96+
const workspaceKeys = await db
8397
.select({
98+
id: workspaceApiKey.id,
8499
key: workspaceApiKey.key,
85100
workspaceId: workspaceApiKey.workspaceId,
86-
workspaceOwnerId: workspace.ownerId,
87101
})
88102
.from(workspaceApiKey)
89103
.leftJoin(workspace, eq(workspaceApiKey.workspaceId, workspace.id))
90-
.where(
91-
and(
92-
eq(workspaceApiKey.key, apiKeyHeader),
93-
eq(workspace.id, workflow.workspaceId) // Key must belong to the same workspace as the workflow
94-
)
95-
)
96-
.limit(1)
97-
98-
workspaceKey = wsKey
104+
.where(eq(workspace.id, workflow.workspaceId)) // Key must belong to the same workspace as the workflow
105+
106+
// Check each workspace key with authentication function
107+
for (const key of workspaceKeys) {
108+
const isValid = await authenticateApiKey(apiKeyHeader, key.key)
109+
if (isValid) {
110+
validWorkspaceKey = key
111+
break
112+
}
113+
}
99114
}
100115

101116
// If neither personal nor workspace key is valid, reject
102-
if (!personalKey && !workspaceKey) {
117+
if (!validPersonalKey && !validWorkspaceKey) {
103118
return {
104119
error: {
105120
message: 'Unauthorized: Invalid API key',
@@ -108,14 +123,52 @@ export async function validateWorkflowAccess(
108123
}
109124
}
110125

111-
// Update last used for the key that was found
112-
if (personalKey) {
113-
await db.update(apiKey).set({ lastUsed: new Date() }).where(eq(apiKey.key, apiKeyHeader))
114-
} else if (workspaceKey) {
126+
// Update last used and potentially migrate to hashed format
127+
if (validPersonalKey) {
128+
const updates: any = { lastUsed: new Date() }
129+
130+
// If this is a plain text key, migrate it to hashed format
131+
if (!isHashedKey(validPersonalKey.key)) {
132+
try {
133+
const hashedKey = await migrateKeyToHashed(apiKeyHeader)
134+
updates.key = hashedKey
135+
logger.info('Migrated personal API key to hashed format', {
136+
keyId: validPersonalKey.id,
137+
})
138+
} catch (error) {
139+
logger.error('Failed to migrate personal API key to hashed format', {
140+
keyId: validPersonalKey.id,
141+
error,
142+
})
143+
// Continue without migration on error
144+
}
145+
}
146+
147+
await db.update(apiKey).set(updates).where(eq(apiKey.id, validPersonalKey.id))
148+
} else if (validWorkspaceKey) {
149+
const updates: any = { lastUsed: new Date() }
150+
151+
// If this is a plain text key, migrate it to hashed format
152+
if (!isHashedKey(validWorkspaceKey.key)) {
153+
try {
154+
const hashedKey = await migrateKeyToHashed(apiKeyHeader)
155+
updates.key = hashedKey
156+
logger.info('Migrated workspace API key to hashed format', {
157+
keyId: validWorkspaceKey.id,
158+
})
159+
} catch (error) {
160+
logger.error('Failed to migrate workspace API key to hashed format', {
161+
keyId: validWorkspaceKey.id,
162+
error,
163+
})
164+
// Continue without migration on error
165+
}
166+
}
167+
115168
await db
116169
.update(workspaceApiKey)
117-
.set({ lastUsed: new Date() })
118-
.where(eq(workspaceApiKey.key, apiKeyHeader))
170+
.set(updates)
171+
.where(eq(workspaceApiKey.id, validWorkspaceKey.id))
119172
}
120173
}
121174
}

apps/sim/app/api/workspaces/[id]/api-keys/route.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { z } from 'zod'
55
import { getSession } from '@/lib/auth'
66
import { createLogger } from '@/lib/logs/console/logger'
77
import { getUserEntityPermissions } from '@/lib/permissions/utils'
8-
import { generateApiKey, generateRequestId } from '@/lib/utils'
8+
import { createApiKey } from '@/lib/security/api-key-auth'
9+
import { generateRequestId } from '@/lib/utils'
910
import { db } from '@/db'
1011
import { apiKey, workspace, workspaceApiKey } from '@/db/schema'
1112

@@ -129,29 +130,36 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
129130
)
130131
}
131132

132-
const keyValue = generateApiKey()
133+
// Create new API key with hashing
134+
const { key: plainKey, hashedKey } = await createApiKey(true)
133135

134-
// Insert the new workspace API key
136+
// Store the hashed version in the database
135137
const [newKey] = await db
136138
.insert(workspaceApiKey)
137139
.values({
138140
id: nanoid(),
139141
workspaceId,
140142
createdBy: userId,
141143
name,
142-
key: keyValue,
144+
key: hashedKey!, // Store the hashed version
143145
createdAt: new Date(),
144146
updatedAt: new Date(),
145147
})
146148
.returning({
147149
id: workspaceApiKey.id,
148150
name: workspaceApiKey.name,
149-
key: workspaceApiKey.key,
150151
createdAt: workspaceApiKey.createdAt,
151152
})
152153

153154
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
154-
return NextResponse.json({ key: newKey })
155+
156+
// Return the plain key to the user (they'll never see it again)
157+
return NextResponse.json({
158+
key: {
159+
...newKey,
160+
key: plainKey, // Return the plain text key for user to copy
161+
},
162+
})
155163
} catch (error: any) {
156164
logger.error(`[${requestId}] Workspace API key POST error`, error)
157165
return NextResponse.json(
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
authenticateApiKey,
4+
createApiKey,
5+
hashApiKey,
6+
isHashedKey,
7+
isValidApiKeyFormat,
8+
migrateKeyToHashed,
9+
} from './api-key-auth'
10+
11+
describe('API Key Authentication', () => {
12+
it.concurrent('should detect hashed keys correctly', () => {
13+
expect(isHashedKey('$2a$12$abcdef')).toBe(true)
14+
expect(isHashedKey('$2b$12$abcdef')).toBe(true)
15+
expect(isHashedKey('$2y$12$abcdef')).toBe(true)
16+
expect(isHashedKey('plain-text-key')).toBe(false)
17+
expect(isHashedKey('sk_live_123456')).toBe(false)
18+
})
19+
20+
it.concurrent('should authenticate plain text keys (legacy)', async () => {
21+
const plainKey = 'sk_live_test_123456'
22+
const storedPlainKey = 'sk_live_test_123456'
23+
24+
const result = await authenticateApiKey(plainKey, storedPlainKey)
25+
expect(result).toBe(true)
26+
})
27+
28+
it.concurrent('should reject invalid plain text keys', async () => {
29+
const inputKey = 'sk_live_test_123456'
30+
const storedKey = 'sk_live_test_different'
31+
32+
const result = await authenticateApiKey(inputKey, storedKey)
33+
expect(result).toBe(false)
34+
})
35+
36+
it.concurrent('should authenticate hashed keys', async () => {
37+
const plainKey = 'sk_live_test_123456'
38+
const hashedKey = await hashApiKey(plainKey)
39+
40+
const result = await authenticateApiKey(plainKey, hashedKey)
41+
expect(result).toBe(true)
42+
})
43+
44+
it.concurrent('should reject invalid hashed keys', async () => {
45+
const correctKey = 'sk_live_test_123456'
46+
const wrongKey = 'sk_live_test_different'
47+
const hashedKey = await hashApiKey(correctKey)
48+
49+
const result = await authenticateApiKey(wrongKey, hashedKey)
50+
expect(result).toBe(false)
51+
})
52+
53+
it.concurrent('should create API key with hashing', async () => {
54+
const { key, hashedKey } = await createApiKey(true)
55+
56+
expect(key).toBeDefined()
57+
expect(hashedKey).toBeDefined()
58+
expect(isHashedKey(hashedKey!)).toBe(true)
59+
60+
const isValid = await authenticateApiKey(key, hashedKey!)
61+
expect(isValid).toBe(true)
62+
})
63+
64+
it.concurrent('should create API key without hashing', async () => {
65+
const { key, hashedKey } = await createApiKey(false)
66+
67+
expect(key).toBeDefined()
68+
expect(hashedKey).toBeUndefined()
69+
})
70+
71+
it.concurrent('should migrate plain key to hashed format', async () => {
72+
const plainKey = 'sk_live_test_123456'
73+
const hashedKey = await migrateKeyToHashed(plainKey)
74+
75+
expect(isHashedKey(hashedKey)).toBe(true)
76+
77+
const isValid = await authenticateApiKey(plainKey, hashedKey)
78+
expect(isValid).toBe(true)
79+
})
80+
81+
it.concurrent('should validate API key format', () => {
82+
expect(isValidApiKeyFormat('sk_live_test_123456')).toBe(true)
83+
expect(isValidApiKeyFormat('')).toBe(false)
84+
expect(isValidApiKeyFormat('short')).toBe(false)
85+
expect(isValidApiKeyFormat('a'.repeat(250))).toBe(false)
86+
expect(isValidApiKeyFormat('valid-key-12345')).toBe(true)
87+
})
88+
89+
it.concurrent('should handle backward compatibility - mixed key types', async () => {
90+
const plainKey = 'sk_live_test_123456'
91+
92+
const plainResult = await authenticateApiKey(plainKey, plainKey)
93+
expect(plainResult).toBe(true)
94+
95+
const hashedStoredKey = await hashApiKey(plainKey)
96+
const hashedResult = await authenticateApiKey(plainKey, hashedStoredKey)
97+
expect(hashedResult).toBe(true)
98+
99+
expect(plainResult).toBe(hashedResult)
100+
})
101+
})

0 commit comments

Comments
 (0)