Skip to content

Commit 065fc5b

Browse files
authored
feat(api-keys): add workspace level api keys to share with other workspace members, add encryption for api keys (#1323)
* update infra and remove railway * feat(api-keys): add workspace-level api keys * encrypt api keys * Revert "update infra and remove railway" This reverts commit b23258a. * reran migrations * tested workspace keys * consolidated code * more consolidation * cleanup * consolidate, remove unused code * add dummy key for ci * continue with regular path for self-hosted folks that don't have key set * fix tests * fix test * remove tests * removed ci additions
1 parent 3798c56 commit 065fc5b

File tree

38 files changed

+8695
-501
lines changed

38 files changed

+8695
-501
lines changed

apps/sim/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ BETTER_AUTH_URL=http://localhost:3000
1212
NEXT_PUBLIC_APP_URL=http://localhost:3000
1313

1414
# Security (Required)
15-
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate
15+
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
16+
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes
1617

1718
# Email Provider (Optional)
1819
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails

apps/sim/app/api/jobs/[jobId]/route.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { runs } from '@trigger.dev/sdk'
2-
import { eq } from 'drizzle-orm'
32
import { type NextRequest, NextResponse } from 'next/server'
3+
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
44
import { getSession } from '@/lib/auth'
55
import { createLogger } from '@/lib/logs/console/logger'
66
import { generateRequestId } from '@/lib/utils'
77
import { createErrorResponse } from '@/app/api/workflows/utils'
8-
import { db } from '@/db'
9-
import { apiKey as apiKeyTable } from '@/db/schema'
108

119
const logger = createLogger('TaskStatusAPI')
1210

@@ -27,14 +25,17 @@ export async function GET(
2725
if (!authenticatedUserId) {
2826
const apiKeyHeader = request.headers.get('x-api-key')
2927
if (apiKeyHeader) {
30-
const [apiKeyRecord] = await db
31-
.select({ userId: apiKeyTable.userId })
32-
.from(apiKeyTable)
33-
.where(eq(apiKeyTable.key, apiKeyHeader))
34-
.limit(1)
35-
36-
if (apiKeyRecord) {
37-
authenticatedUserId = apiKeyRecord.userId
28+
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
29+
if (authResult.success && authResult.userId) {
30+
authenticatedUserId = authResult.userId
31+
if (authResult.keyId) {
32+
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
33+
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
34+
keyId: authResult.keyId,
35+
error,
36+
})
37+
})
38+
}
3839
}
3940
}
4041
}

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

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { eq } from 'drizzle-orm'
1+
import { and, eq } from 'drizzle-orm'
22
import { nanoid } from 'nanoid'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
45
import { getSession } from '@/lib/auth'
56
import { createLogger } from '@/lib/logs/console/logger'
6-
import { generateApiKey } from '@/lib/utils'
77
import { db } from '@/db'
88
import { apiKey } from '@/db/schema'
99

@@ -19,7 +19,6 @@ export async function GET(request: NextRequest) {
1919

2020
const userId = session.user.id
2121

22-
// Fetch all API keys for this user
2322
const keys = await db
2423
.select({
2524
id: apiKey.id,
@@ -30,13 +29,19 @@ export async function GET(request: NextRequest) {
3029
expiresAt: apiKey.expiresAt,
3130
})
3231
.from(apiKey)
33-
.where(eq(apiKey.userId, userId))
32+
.where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
3433
.orderBy(apiKey.createdAt)
3534

36-
const maskedKeys = keys.map((key) => ({
37-
...key,
38-
key: key.key,
39-
}))
35+
const maskedKeys = await Promise.all(
36+
keys.map(async (key) => {
37+
const displayFormat = await getApiKeyDisplayFormat(key.key)
38+
return {
39+
...key,
40+
key: key.key,
41+
displayKey: displayFormat,
42+
}
43+
})
44+
)
4045

4146
return NextResponse.json({ keys: maskedKeys })
4247
} catch (error) {
@@ -56,33 +61,61 @@ export async function POST(request: NextRequest) {
5661
const userId = session.user.id
5762
const body = await request.json()
5863

59-
// Validate the request
60-
const { name } = body
61-
if (!name || typeof name !== 'string') {
64+
const { name: rawName } = body
65+
if (!rawName || typeof rawName !== 'string') {
6266
return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 })
6367
}
6468

65-
const keyValue = generateApiKey()
69+
const name = rawName.trim()
70+
if (!name) {
71+
return NextResponse.json({ error: 'Name cannot be empty.' }, { status: 400 })
72+
}
73+
74+
const existingKey = await db
75+
.select()
76+
.from(apiKey)
77+
.where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal')))
78+
.limit(1)
79+
80+
if (existingKey.length > 0) {
81+
return NextResponse.json(
82+
{
83+
error: `A personal API key named "${name}" already exists. Please choose a different name.`,
84+
},
85+
{ status: 409 }
86+
)
87+
}
88+
89+
const { key: plainKey, encryptedKey } = await createApiKey(true)
90+
91+
if (!encryptedKey) {
92+
throw new Error('Failed to encrypt API key for storage')
93+
}
6694

67-
// Insert the new API key
6895
const [newKey] = await db
6996
.insert(apiKey)
7097
.values({
7198
id: nanoid(),
7299
userId,
100+
workspaceId: null,
73101
name,
74-
key: keyValue,
102+
key: encryptedKey,
103+
type: 'personal',
75104
createdAt: new Date(),
76105
updatedAt: new Date(),
77106
})
78107
.returning({
79108
id: apiKey.id,
80109
name: apiKey.name,
81-
key: apiKey.key,
82110
createdAt: apiKey.createdAt,
83111
})
84112

85-
return NextResponse.json({ key: newKey })
113+
return NextResponse.json({
114+
key: {
115+
...newKey,
116+
key: plainKey,
117+
},
118+
})
86119
} catch (error) {
87120
logger.error('Failed to create API key', { error })
88121
return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 })

apps/sim/app/api/v1/auth.ts

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import { eq } from 'drizzle-orm'
21
import type { NextRequest } from 'next/server'
2+
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
33
import { createLogger } from '@/lib/logs/console/logger'
4-
import { db } from '@/db'
5-
import { apiKey as apiKeyTable } from '@/db/schema'
64

75
const logger = createLogger('V1Auth')
86

97
export interface AuthResult {
108
authenticated: boolean
119
userId?: string
10+
workspaceId?: string
11+
keyType?: 'personal' | 'workspace'
1212
error?: string
1313
}
1414

15-
export async function authenticateApiKey(request: NextRequest): Promise<AuthResult> {
15+
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
1616
const apiKey = request.headers.get('x-api-key')
1717

1818
if (!apiKey) {
@@ -23,36 +23,23 @@ export async function authenticateApiKey(request: NextRequest): Promise<AuthResu
2323
}
2424

2525
try {
26-
const [keyRecord] = await db
27-
.select({
28-
userId: apiKeyTable.userId,
29-
expiresAt: apiKeyTable.expiresAt,
30-
})
31-
.from(apiKeyTable)
32-
.where(eq(apiKeyTable.key, apiKey))
33-
.limit(1)
26+
const result = await authenticateApiKeyFromHeader(apiKey)
3427

35-
if (!keyRecord) {
28+
if (!result.success) {
3629
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
3730
return {
3831
authenticated: false,
39-
error: 'Invalid API key',
32+
error: result.error || 'Invalid API key',
4033
}
4134
}
4235

43-
if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
44-
logger.warn('Expired API key attempted', { userId: keyRecord.userId })
45-
return {
46-
authenticated: false,
47-
error: 'API key expired',
48-
}
49-
}
50-
51-
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.key, apiKey))
36+
await updateApiKeyLastUsed(result.keyId!)
5237

5338
return {
5439
authenticated: true,
55-
userId: keyRecord.userId,
40+
userId: result.userId!,
41+
workspaceId: result.workspaceId,
42+
keyType: result.keyType,
5643
}
5744
} catch (error) {
5845
logger.error('API key authentication error', { error })

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
33
import { createLogger } from '@/lib/logs/console/logger'
44
import { RateLimiter } from '@/services/queue/RateLimiter'
5-
import { authenticateApiKey } from './auth'
5+
import { authenticateV1Request } from './auth'
66

77
const logger = createLogger('V1Middleware')
88
const rateLimiter = new RateLimiter()
@@ -21,7 +21,7 @@ export async function checkRateLimit(
2121
endpoint: 'logs' | 'logs-detail' = 'logs'
2222
): Promise<RateLimitResult> {
2323
try {
24-
const auth = await authenticateApiKey(request)
24+
const auth = await authenticateV1Request(request)
2525
if (!auth.authenticated) {
2626
return {
2727
allowed: false,

0 commit comments

Comments
 (0)