chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Tests for API key authentication utilities.
|
||||
*
|
||||
* Tests cover:
|
||||
* - API key format detection (legacy vs encrypted)
|
||||
* - Authentication against stored keys
|
||||
* - Key encryption and decryption
|
||||
* - Display formatting
|
||||
* - Edge cases
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { createEncryptedApiKey, createLegacyApiKey } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const cryptoMock = vi.hoisted(() => ({
|
||||
isEncryptedApiKeyFormat: (key: string) => key.startsWith('sk-sim-'),
|
||||
isLegacyApiKeyFormat: (key: string) => key.startsWith('sim_') && !key.startsWith('sk-sim-'),
|
||||
generateApiKey: () => `sim_${randomBytes(24).toString('base64url')}`,
|
||||
generateEncryptedApiKey: () => `sk-sim-${randomBytes(24).toString('base64url')}`,
|
||||
encryptApiKey: async (apiKey: string) => ({
|
||||
encrypted: `mock-iv:${Buffer.from(apiKey).toString('hex')}:mock-tag`,
|
||||
iv: 'mock-iv',
|
||||
}),
|
||||
decryptApiKey: async (encryptedValue: string) => {
|
||||
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
const parts = encryptedValue.split(':')
|
||||
const hexPart = parts[1]
|
||||
return { decrypted: Buffer.from(hexPart, 'hex').toString('utf8') }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-key/crypto', () => cryptoMock)
|
||||
|
||||
import {
|
||||
formatApiKeyForDisplay,
|
||||
getApiKeyLast4,
|
||||
isEncryptedKey,
|
||||
isValidApiKeyFormat,
|
||||
} from '@/lib/api-key/auth'
|
||||
|
||||
const { generateApiKey, generateEncryptedApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat } =
|
||||
cryptoMock
|
||||
|
||||
describe('isEncryptedKey', () => {
|
||||
it('should detect encrypted storage format (iv:encrypted:authTag)', () => {
|
||||
const encryptedStorage = 'abc123:encrypted-data:tag456'
|
||||
expect(isEncryptedKey(encryptedStorage)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect plain text storage (no colons)', () => {
|
||||
const plainKey = 'sim_abcdef123456'
|
||||
expect(isEncryptedKey(plainKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect plain text with single colon', () => {
|
||||
const singleColon = 'part1:part2'
|
||||
expect(isEncryptedKey(singleColon)).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect encrypted format with exactly 3 parts', () => {
|
||||
const threeParts = 'iv:data:tag'
|
||||
expect(isEncryptedKey(threeParts)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject format with more than 3 parts', () => {
|
||||
const fourParts = 'a:b:c:d'
|
||||
expect(isEncryptedKey(fourParts)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isEncryptedKey('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEncryptedApiKeyFormat (key prefix)', () => {
|
||||
it('should detect sk-sim- prefix as encrypted format', () => {
|
||||
const { key } = createEncryptedApiKey()
|
||||
expect(isEncryptedApiKeyFormat(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect sim_ prefix as encrypted format', () => {
|
||||
const { key } = createLegacyApiKey()
|
||||
expect(isEncryptedApiKeyFormat(key)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect random string as encrypted format', () => {
|
||||
expect(isEncryptedApiKeyFormat('random-string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyApiKeyFormat', () => {
|
||||
it('should detect sim_ prefix as legacy format', () => {
|
||||
const { key } = createLegacyApiKey()
|
||||
expect(isLegacyApiKeyFormat(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect sk-sim- prefix as legacy format', () => {
|
||||
const { key } = createEncryptedApiKey()
|
||||
expect(isLegacyApiKeyFormat(key)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect random string as legacy format', () => {
|
||||
expect(isLegacyApiKeyFormat('random-string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidApiKeyFormat', () => {
|
||||
it('should accept valid length keys', () => {
|
||||
expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject too short keys', () => {
|
||||
expect(isValidApiKeyFormat('short')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject too long keys (>200 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(201))).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept keys at boundary (11 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(11))).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject keys at boundary (10 chars)', () => {
|
||||
expect(isValidApiKeyFormat('a'.repeat(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject non-string input', () => {
|
||||
expect(isValidApiKeyFormat(null as any)).toBe(false)
|
||||
expect(isValidApiKeyFormat(undefined as any)).toBe(false)
|
||||
expect(isValidApiKeyFormat(123 as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidApiKeyFormat('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getApiKeyLast4', () => {
|
||||
it('should return last 4 characters of key', () => {
|
||||
expect(getApiKeyLast4('sim_abcdefghijklmnop')).toBe('mnop')
|
||||
})
|
||||
|
||||
it('should return last 4 characters of encrypted format key', () => {
|
||||
expect(getApiKeyLast4('sk-sim-abcdefghijkl')).toBe('ijkl')
|
||||
})
|
||||
|
||||
it('should return entire key if less than 4 chars', () => {
|
||||
expect(getApiKeyLast4('abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('should handle exactly 4 chars', () => {
|
||||
expect(getApiKeyLast4('abcd')).toBe('abcd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatApiKeyForDisplay', () => {
|
||||
it('should format encrypted format key with sk-sim- prefix', () => {
|
||||
const key = 'sk-sim-abcdefghijklmnopqrstuvwx'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('sk-sim-...uvwx')
|
||||
})
|
||||
|
||||
it('should format legacy key with sim_ prefix', () => {
|
||||
const key = 'sim_abcdefghijklmnopqrstuvwx'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('sim_...uvwx')
|
||||
})
|
||||
|
||||
it('should format unknown format key with just ellipsis', () => {
|
||||
const key = 'custom-key-format-abcd'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toBe('...abcd')
|
||||
})
|
||||
|
||||
it('should show last 4 characters correctly', () => {
|
||||
const key = 'sk-sim-xxxxxxxxxxxxxxxxr6AA'
|
||||
const formatted = formatApiKeyForDisplay(key)
|
||||
expect(formatted).toContain('r6AA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('should generate key with sim_ prefix', () => {
|
||||
const key = generateApiKey()
|
||||
expect(key).toMatch(/^sim_/)
|
||||
})
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const key1 = generateApiKey()
|
||||
const key2 = generateApiKey()
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate key of valid length', () => {
|
||||
const key = generateApiKey()
|
||||
expect(key.length).toBeGreaterThan(10)
|
||||
expect(key.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateEncryptedApiKey', () => {
|
||||
it('should generate key with sk-sim- prefix', () => {
|
||||
const key = generateEncryptedApiKey()
|
||||
expect(key).toMatch(/^sk-sim-/)
|
||||
})
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const key1 = generateEncryptedApiKey()
|
||||
const key2 = generateEncryptedApiKey()
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate key of valid length', () => {
|
||||
const key = generateEncryptedApiKey()
|
||||
expect(key.length).toBeGreaterThan(10)
|
||||
expect(key.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
decryptApiKey,
|
||||
encryptApiKey,
|
||||
generateApiKey,
|
||||
generateEncryptedApiKey,
|
||||
hashApiKey,
|
||||
isEncryptedApiKeyFormat,
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/crypto'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('ApiKeyAuth')
|
||||
|
||||
/**
|
||||
* API key authentication utilities supporting both legacy plain text keys
|
||||
* and modern encrypted keys for gradual migration without breaking existing keys
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if a stored key is in the new encrypted format
|
||||
* @param storedKey - The key stored in the database
|
||||
* @returns true if the key is encrypted, false if it's plain text
|
||||
*/
|
||||
export function isEncryptedKey(storedKey: string): boolean {
|
||||
// Check if it follows the encrypted format: iv:encrypted:authTag
|
||||
return storedKey.includes(':') && storedKey.split(':').length === 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an API key for secure storage
|
||||
* @param apiKey - The plain text API key to encrypt
|
||||
* @returns Promise<string> - The encrypted key
|
||||
*/
|
||||
async function encryptApiKeyForStorage(apiKey: string): Promise<string> {
|
||||
try {
|
||||
const { encrypted } = await encryptApiKey(apiKey)
|
||||
return encrypted
|
||||
} catch (error) {
|
||||
logger.error('API key encryption error:', { error })
|
||||
throw new Error('Failed to encrypt API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new API key
|
||||
* @param useStorage - Whether to encrypt the key before storage (default: true)
|
||||
* @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version
|
||||
*/
|
||||
export async function createApiKey(useStorage = true): Promise<{
|
||||
key: string
|
||||
encryptedKey?: string
|
||||
}> {
|
||||
try {
|
||||
const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined
|
||||
|
||||
const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey()
|
||||
|
||||
if (useStorage) {
|
||||
const encryptedKey = await encryptApiKeyForStorage(plainKey)
|
||||
return { key: plainKey, encryptedKey }
|
||||
}
|
||||
|
||||
return { key: plainKey }
|
||||
} catch (error) {
|
||||
logger.error('API key creation error:', { error })
|
||||
throw new Error('Failed to create API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an API key from storage for display purposes
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The decrypted API key
|
||||
*/
|
||||
async function decryptApiKeyFromStorage(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(encryptedKey)
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('API key decryption error:', { error })
|
||||
throw new Error('Failed to decrypt API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last 4 characters of an API key for display purposes
|
||||
* @param apiKey - The API key (plain text)
|
||||
* @returns string - The last 4 characters
|
||||
*/
|
||||
export function getApiKeyLast4(apiKey: string): string {
|
||||
return apiKey.slice(-4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display format for an API key showing prefix and last 4 characters
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The display format like "sk-sim-...r6AA"
|
||||
*/
|
||||
export async function getApiKeyDisplayFormat(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
if (isEncryptedKey(encryptedKey)) {
|
||||
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
|
||||
return formatApiKeyForDisplay(decryptedKey)
|
||||
}
|
||||
// For plain text keys (legacy), format directly
|
||||
return formatApiKeyForDisplay(encryptedKey)
|
||||
} catch (error) {
|
||||
logger.error('Failed to format API key for display:', { error })
|
||||
return '****'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an API key for display showing prefix and last 4 characters
|
||||
* @param apiKey - The API key (plain text)
|
||||
* @returns string - The display format like "sk-sim-...r6AA" or "sim_...r6AA"
|
||||
*/
|
||||
export function formatApiKeyForDisplay(apiKey: string): string {
|
||||
if (isEncryptedApiKeyFormat(apiKey)) {
|
||||
// For sk-sim- format: "sk-sim-...r6AA"
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `sk-sim-...${last4}`
|
||||
}
|
||||
if (isLegacyApiKeyFormat(apiKey)) {
|
||||
// For sim_ format: "sim_...r6AA"
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `sim_...${last4}`
|
||||
}
|
||||
// Unknown format, just show last 4
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `...${last4}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last 4 characters of an encrypted API key by decrypting it first
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The last 4 characters
|
||||
*/
|
||||
async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
if (isEncryptedKey(encryptedKey)) {
|
||||
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
|
||||
return getApiKeyLast4(decryptedKey)
|
||||
}
|
||||
// For plain text keys (legacy), return last 4 directly
|
||||
return getApiKeyLast4(encryptedKey)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get last 4 characters of API key:', { error })
|
||||
return '****'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates API key format (basic validation)
|
||||
* @param apiKey - The API key to validate
|
||||
* @returns boolean - true if the format appears valid
|
||||
*/
|
||||
export function isValidApiKeyFormat(apiKeyValue: string): boolean {
|
||||
return typeof apiKeyValue === 'string' && apiKeyValue.length > 10 && apiKeyValue.length < 200
|
||||
}
|
||||
|
||||
export async function createWorkspaceApiKey(params: {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
name: string
|
||||
}) {
|
||||
const existingKey = await db
|
||||
.select({ id: apiKey.id })
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, params.workspaceId),
|
||||
eq(apiKey.name, params.name),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length > 0) {
|
||||
throw new Error(
|
||||
`A workspace API key named "${params.name}" already exists. Choose a different name.`
|
||||
)
|
||||
}
|
||||
|
||||
const { key: plainKey, encryptedKey } = await createApiKey(true)
|
||||
if (!encryptedKey) {
|
||||
throw new Error('Failed to encrypt API key for storage')
|
||||
}
|
||||
|
||||
const [newKey] = await db
|
||||
.insert(apiKey)
|
||||
.values({
|
||||
id: generateShortId(),
|
||||
workspaceId: params.workspaceId,
|
||||
userId: params.userId,
|
||||
createdBy: params.userId,
|
||||
name: params.name,
|
||||
key: encryptedKey,
|
||||
keyHash: hashApiKey(plainKey),
|
||||
type: 'workspace',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({ id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt })
|
||||
|
||||
return { id: newKey.id, name: newKey.name, key: plainKey, createdAt: newKey.createdAt }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({
|
||||
mockOrderBy: vi.fn(),
|
||||
mockDecryptSecret: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ orderBy: mockOrderBy })),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => ({
|
||||
decryptSecret: mockDecryptSecret,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/api-keys', () => ({
|
||||
getRotatingApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isHosted: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/providers/models', () => ({
|
||||
getProviderFileAttachment: vi
|
||||
.fn()
|
||||
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
|
||||
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
|
||||
getHostedModels: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/providers/utils', () => ({
|
||||
PROVIDER_PLACEHOLDER_KEY: 'placeholder',
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/providers/store', () => ({
|
||||
useProvidersStore: { getState: vi.fn() },
|
||||
}))
|
||||
|
||||
import { getBYOKKey } from '@/lib/api-key/byok'
|
||||
|
||||
/**
|
||||
* Rotation counters in the module under test are keyed by
|
||||
* `${workspaceId}:${providerId}` and persist for the process lifetime, so
|
||||
* each test uses a unique workspace id to start from a fresh cursor.
|
||||
*/
|
||||
let testIndex = 0
|
||||
const uniqueWorkspaceId = () => `workspace-${++testIndex}`
|
||||
|
||||
const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` })
|
||||
|
||||
describe('getBYOKKey', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOrderBy.mockResolvedValue([])
|
||||
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
|
||||
decrypted: encrypted.replace('encrypted-', 'decrypted-'),
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns null when no workspaceId is provided', async () => {
|
||||
expect(await getBYOKKey(undefined, 'openai')).toBeNull()
|
||||
expect(await getBYOKKey(null, 'openai')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the workspace has no keys for the provider', async () => {
|
||||
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the same key on every call when only one key is stored', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1')])
|
||||
|
||||
for (let call = 0; call < 3; call++) {
|
||||
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
|
||||
apiKey: 'decrypted-key-1',
|
||||
isBYOK: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('round-robins across multiple keys in creation order', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')])
|
||||
|
||||
const apiKeys = []
|
||||
for (let call = 0; call < 4; call++) {
|
||||
const result = await getBYOKKey(workspaceId, 'openai')
|
||||
apiKeys.push(result?.apiKey)
|
||||
}
|
||||
|
||||
expect(apiKeys).toEqual([
|
||||
'decrypted-key-1',
|
||||
'decrypted-key-2',
|
||||
'decrypted-key-3',
|
||||
'decrypted-key-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the key list fresh from the database on every call', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1')])
|
||||
|
||||
await getBYOKKey(workspaceId, 'openai')
|
||||
await getBYOKKey(workspaceId, 'openai')
|
||||
await getBYOKKey(workspaceId, 'openai')
|
||||
|
||||
expect(mockOrderBy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('tracks rotation independently per provider within a workspace', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
||||
|
||||
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1')
|
||||
expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1')
|
||||
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-2')
|
||||
})
|
||||
|
||||
it('skips a key that fails to decrypt and returns the next one', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
||||
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
|
||||
if (encrypted === 'encrypted-key-1') {
|
||||
throw new Error('corrupt ciphertext')
|
||||
}
|
||||
return { decrypted: encrypted.replace('encrypted-', 'decrypted-') }
|
||||
})
|
||||
|
||||
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
|
||||
apiKey: 'decrypted-key-2',
|
||||
isBYOK: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when every key fails to decrypt', async () => {
|
||||
const workspaceId = uniqueWorkspaceId()
|
||||
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
|
||||
mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext'))
|
||||
|
||||
expect(await getBYOKKey(workspaceId, 'openai')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the keys query throws', async () => {
|
||||
mockOrderBy.mockRejectedValue(new Error('database unavailable'))
|
||||
|
||||
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getHostedModels } from '@/providers/models'
|
||||
import { PROVIDER_PLACEHOLDER_KEY } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import type { BYOKProviderId } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('BYOKKeys')
|
||||
|
||||
export interface BYOKKeyResult {
|
||||
apiKey: string
|
||||
isBYOK: true
|
||||
}
|
||||
|
||||
const rotationCounters = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Advances the per-process round-robin cursor for a rotation pool and returns
|
||||
* the next index. Counters are per server instance, which keeps rotation free
|
||||
* of database writes; aggregate load still spreads evenly across keys.
|
||||
*/
|
||||
function nextRotationIndex(poolKey: string, poolSize: number): number {
|
||||
const cursor = (rotationCounters.get(poolKey) ?? -1) + 1
|
||||
rotationCounters.set(poolKey, cursor)
|
||||
return cursor % poolSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a workspace BYOK key for a provider. When the workspace has
|
||||
* multiple keys stored for the provider, requests round-robin across them in
|
||||
* creation order. A key that fails to decrypt is skipped in favor of the next
|
||||
* one in the pool.
|
||||
*
|
||||
* The key list is read fresh every call (not cached): BYOK is not a hot query,
|
||||
* and reading fresh keeps revocation immediate across ECS tasks.
|
||||
*/
|
||||
export async function getBYOKKey(
|
||||
workspaceId: string | undefined | null,
|
||||
providerId: BYOKProviderId
|
||||
): Promise<BYOKKeyResult | null> {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await db
|
||||
.select({ id: workspaceBYOKKeys.id, encryptedApiKey: workspaceBYOKKeys.encryptedApiKey })
|
||||
.from(workspaceBYOKKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceBYOKKeys.workspaceId, workspaceId),
|
||||
eq(workspaceBYOKKeys.providerId, providerId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workspaceBYOKKeys.createdAt), asc(workspaceBYOKKeys.id))
|
||||
|
||||
if (!keys.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndex = nextRotationIndex(`${workspaceId}:${providerId}`, keys.length)
|
||||
for (let offset = 0; offset < keys.length; offset++) {
|
||||
const key = keys[(startIndex + offset) % keys.length]
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(key.encryptedApiKey)
|
||||
return { apiKey: decrypted, isBYOK: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to decrypt BYOK key, skipping', {
|
||||
workspaceId,
|
||||
providerId,
|
||||
keyId: key.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed to get BYOK key', { workspaceId, providerId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiKeyWithBYOK(
|
||||
provider: string,
|
||||
model: string,
|
||||
workspaceId: string | undefined | null,
|
||||
userProvidedKey?: string
|
||||
): Promise<{ apiKey: string; isBYOK: boolean }> {
|
||||
const isOllamaModel =
|
||||
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
|
||||
if (isOllamaModel) {
|
||||
return { apiKey: 'empty', isBYOK: false }
|
||||
}
|
||||
|
||||
const isVllmModel =
|
||||
provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model)
|
||||
if (isVllmModel) {
|
||||
return { apiKey: userProvidedKey || env.VLLM_API_KEY || 'empty', isBYOK: false }
|
||||
}
|
||||
|
||||
const isLitellmModel =
|
||||
provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model)
|
||||
if (isLitellmModel) {
|
||||
return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false }
|
||||
}
|
||||
|
||||
const isFireworksModel =
|
||||
provider === 'fireworks' ||
|
||||
useProvidersStore.getState().providers.fireworks.models.includes(model)
|
||||
if (isFireworksModel) {
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'fireworks')
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key for Fireworks', { model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
}
|
||||
if (userProvidedKey) {
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
if (env.FIREWORKS_API_KEY) {
|
||||
return { apiKey: env.FIREWORKS_API_KEY, isBYOK: false }
|
||||
}
|
||||
throw new Error(`API key is required for Fireworks ${model}`)
|
||||
}
|
||||
|
||||
const isTogetherModel =
|
||||
provider === 'together' ||
|
||||
useProvidersStore.getState().providers.together.models.includes(model)
|
||||
if (isTogetherModel) {
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'together')
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key for Together AI', { model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
}
|
||||
if (userProvidedKey) {
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
if (env.TOGETHER_API_KEY) {
|
||||
return { apiKey: env.TOGETHER_API_KEY, isBYOK: false }
|
||||
}
|
||||
throw new Error(`API key is required for Together AI ${model}`)
|
||||
}
|
||||
|
||||
const isBasetenModel =
|
||||
provider === 'baseten' || useProvidersStore.getState().providers.baseten.models.includes(model)
|
||||
if (isBasetenModel) {
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'baseten')
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key for Baseten', { model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
}
|
||||
if (userProvidedKey) {
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
if (env.BASETEN_API_KEY) {
|
||||
return { apiKey: env.BASETEN_API_KEY, isBYOK: false }
|
||||
}
|
||||
throw new Error(`API key is required for Baseten ${model}`)
|
||||
}
|
||||
|
||||
const isOllamaCloudModel =
|
||||
provider === 'ollama-cloud' ||
|
||||
useProvidersStore.getState().providers['ollama-cloud'].models.includes(model)
|
||||
if (isOllamaCloudModel) {
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud')
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key for Ollama Cloud', { model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
}
|
||||
if (userProvidedKey) {
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
throw new Error(`API key is required for Ollama Cloud ${model}`)
|
||||
}
|
||||
|
||||
const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/')
|
||||
if (isBedrockModel) {
|
||||
return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false }
|
||||
}
|
||||
|
||||
if (provider === 'azure-openai') {
|
||||
return { apiKey: userProvidedKey || env.AZURE_OPENAI_API_KEY || '', isBYOK: false }
|
||||
}
|
||||
|
||||
if (provider === 'azure-anthropic') {
|
||||
return { apiKey: userProvidedKey || env.AZURE_ANTHROPIC_API_KEY || '', isBYOK: false }
|
||||
}
|
||||
|
||||
const isOpenAIModel = provider === 'openai'
|
||||
const isClaudeModel = provider === 'anthropic'
|
||||
const isGeminiModel = provider === 'google'
|
||||
const isMistralModel = provider === 'mistral'
|
||||
const isZaiModel = provider === 'zai'
|
||||
const isXaiModel = provider === 'xai'
|
||||
|
||||
const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)
|
||||
|
||||
if (
|
||||
isHosted &&
|
||||
workspaceId &&
|
||||
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel)
|
||||
) {
|
||||
const hostedModels = getHostedModels()
|
||||
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
|
||||
|
||||
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
|
||||
|
||||
if (isModelHosted || isMistralModel) {
|
||||
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
|
||||
if (byokResult) {
|
||||
logger.info('Using BYOK key', { provider, model, workspaceId })
|
||||
return byokResult
|
||||
}
|
||||
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
|
||||
|
||||
if (isModelHosted) {
|
||||
try {
|
||||
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
|
||||
return { apiKey: serverKey, isBYOK: false }
|
||||
} catch (_error) {
|
||||
if (userProvidedKey) {
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
throw new Error(`No API key available for ${provider} ${model}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userProvidedKey) {
|
||||
logger.debug('BYOK not applicable, no user key provided', {
|
||||
provider,
|
||||
model,
|
||||
workspaceId,
|
||||
isHosted,
|
||||
})
|
||||
throw new Error(`API key is required for ${provider} ${model}`)
|
||||
}
|
||||
|
||||
return { apiKey: userProvidedKey, isBYOK: false }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for the API-key crypto primitives.
|
||||
*
|
||||
* `hashApiKey` is the foundation of both the new hash-first authentication
|
||||
* path and the `backfill-api-key-hash` script — the backfill is idempotent
|
||||
* precisely because `hashApiKey` is deterministic and the encrypted round-trip
|
||||
* recovers the same plain-text key on every run.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { randomBytes } from 'crypto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockEnv } = vi.hoisted(() => ({
|
||||
mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
|
||||
import {
|
||||
decryptApiKey,
|
||||
encryptApiKey,
|
||||
hashApiKey,
|
||||
isEncryptedApiKeyFormat,
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/crypto'
|
||||
|
||||
const FIXED_ENCRYPTION_KEY = '0'.repeat(64)
|
||||
|
||||
describe('hashApiKey', () => {
|
||||
it('is deterministic — same input produces same hash', () => {
|
||||
const h1 = hashApiKey('sk-sim-example')
|
||||
const h2 = hashApiKey('sk-sim-example')
|
||||
expect(h1).toBe(h2)
|
||||
})
|
||||
|
||||
it('produces a 64-char hex SHA-256 digest', () => {
|
||||
const hash = hashApiKey('sk-sim-example')
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('produces different hashes for different inputs', () => {
|
||||
expect(hashApiKey('sk-sim-a')).not.toBe(hashApiKey('sk-sim-b'))
|
||||
})
|
||||
|
||||
it('matches the published SHA-256 vector for the empty string', () => {
|
||||
expect(hashApiKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
})
|
||||
|
||||
describe('backfill idempotency — encrypted round-trip', () => {
|
||||
beforeEach(() => {
|
||||
mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY
|
||||
})
|
||||
|
||||
it('re-running the backfill on the same row yields the same keyHash', async () => {
|
||||
const plainKey = `sk-sim-${randomBytes(12).toString('hex')}`
|
||||
const { encrypted } = await encryptApiKey(plainKey)
|
||||
|
||||
const { decrypted: first } = await decryptApiKey(encrypted)
|
||||
const { decrypted: second } = await decryptApiKey(encrypted)
|
||||
|
||||
expect(first).toBe(plainKey)
|
||||
expect(second).toBe(plainKey)
|
||||
expect(hashApiKey(first)).toBe(hashApiKey(second))
|
||||
})
|
||||
|
||||
it('is stable whether the stored key is legacy plain text or encrypted', async () => {
|
||||
const plainKey = 'sim_legacy-format-key'
|
||||
const { encrypted } = await encryptApiKey(plainKey)
|
||||
|
||||
const { decrypted } = await decryptApiKey(encrypted)
|
||||
expect(hashApiKey(decrypted)).toBe(hashApiKey(plainKey))
|
||||
})
|
||||
})
|
||||
|
||||
describe('api-key format helpers', () => {
|
||||
it('treats sk-sim- prefix as the encrypted format', () => {
|
||||
expect(isEncryptedApiKeyFormat('sk-sim-abc')).toBe(true)
|
||||
expect(isLegacyApiKeyFormat('sk-sim-abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats sim_ prefix as the legacy format', () => {
|
||||
expect(isLegacyApiKeyFormat('sim_abc')).toBe(true)
|
||||
expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { decrypt, encrypt } from '@sim/security/encryption'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { generateSecureToken } from '@sim/security/tokens'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('ApiKeyCrypto')
|
||||
|
||||
function getApiEncryptionKey(): Buffer | null {
|
||||
const key = env.API_ENCRYPTION_KEY
|
||||
if (!key) {
|
||||
logger.warn(
|
||||
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (key.length !== 64) {
|
||||
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
|
||||
}
|
||||
return Buffer.from(key, 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an API key using the dedicated API encryption key. Falls back to
|
||||
* returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward
|
||||
* compatibility with deployments that predate encryption-at-rest.
|
||||
*/
|
||||
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const key = getApiEncryptionKey()
|
||||
if (!key) {
|
||||
return { encrypted: apiKey, iv: '' }
|
||||
}
|
||||
return encrypt(apiKey, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an API key previously produced by {@link encryptApiKey}. Values
|
||||
* that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain
|
||||
* text and returned unchanged.
|
||||
*/
|
||||
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
|
||||
const parts = encryptedValue.split(':')
|
||||
if (parts.length !== 3) {
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
|
||||
const key = getApiEncryptionKey()
|
||||
if (!key) {
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
|
||||
try {
|
||||
return await decrypt(encryptedValue, key)
|
||||
} catch (error) {
|
||||
logger.error('API key decryption error:', { error: toError(error).message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a standardized API key with the 'sim_' prefix (legacy format)
|
||||
* @returns A new API key string
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return `sim_${generateSecureToken(24)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new encrypted API key with the 'sk-sim-' prefix
|
||||
* @returns A new encrypted API key string
|
||||
*/
|
||||
export function generateEncryptedApiKey(): string {
|
||||
return `sk-sim-${generateSecureToken(24)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an API key uses the new encrypted format based on prefix
|
||||
* @param apiKey - The API key to check
|
||||
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
|
||||
*/
|
||||
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
|
||||
return apiKey.startsWith('sk-sim-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an API key uses the legacy format based on prefix
|
||||
* @param apiKey - The API key to check
|
||||
* @returns true if the key uses the legacy format (sim_ prefix)
|
||||
*/
|
||||
export function isLegacyApiKeyFormat(apiKey: string): boolean {
|
||||
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically hashes a plain-text API key for indexed lookup. The hash
|
||||
* column has a unique index so authentication can match an incoming key via a
|
||||
* single `WHERE key_hash = $hash` lookup instead of scanning and decrypting
|
||||
* every stored encrypted key.
|
||||
*
|
||||
* @param plainKey - The plain-text API key as presented by the client
|
||||
* @returns The hex-encoded SHA-256 digest
|
||||
*/
|
||||
export function hashApiKey(plainKey: string): string {
|
||||
return sha256Hex(plainKey)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { createWorkspaceApiKey } from '@/lib/api-key/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
|
||||
const logger = createLogger('ApiKeyOrchestration')
|
||||
|
||||
export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal'
|
||||
|
||||
export interface PerformCreateWorkspaceApiKeyParams {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
name: string
|
||||
source?: string
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
}
|
||||
|
||||
export interface PerformCreateWorkspaceApiKeyResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
errorCode?: ApiKeyOrchestrationErrorCode
|
||||
key?: {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
export async function performCreateWorkspaceApiKey(
|
||||
params: PerformCreateWorkspaceApiKeyParams
|
||||
): Promise<PerformCreateWorkspaceApiKeyResult> {
|
||||
try {
|
||||
const key = await createWorkspaceApiKey({
|
||||
workspaceId: params.workspaceId,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
})
|
||||
|
||||
try {
|
||||
PlatformEvents.apiKeyGenerated({
|
||||
userId: params.userId,
|
||||
keyName: params.name,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
logger.info('Created workspace API key', {
|
||||
workspaceId: params.workspaceId,
|
||||
keyId: key.id,
|
||||
name: params.name,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.userId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
action: AuditAction.API_KEY_CREATED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
resourceId: key.id,
|
||||
resourceName: params.name,
|
||||
description: `Created API key "${params.name}"`,
|
||||
metadata: {
|
||||
keyName: params.name,
|
||||
keyType: 'workspace',
|
||||
source: params.source ?? 'settings',
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, key }
|
||||
} catch (error) {
|
||||
const message = toError(error).message
|
||||
logger.error('Failed to create workspace API key', { error })
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for authenticateApiKeyFromHeader.
|
||||
*
|
||||
* Authentication looks up a single row by the SHA-256 hash of the incoming
|
||||
* API key and applies the scope / expiry / permission gates. Any miss — no
|
||||
* matching hash or a failed gate — returns an invalid result.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { serviceLogger } = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
withMetadata: vi.fn(),
|
||||
}
|
||||
logger.child.mockReturnValue(logger)
|
||||
logger.withMetadata.mockReturnValue(logger)
|
||||
return { serviceLogger: logger }
|
||||
})
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn(() => serviceLogger),
|
||||
logger: serviceLogger,
|
||||
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
|
||||
getRequestContext: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({
|
||||
mockGetWorkspaceBillingSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
|
||||
}))
|
||||
|
||||
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
|
||||
import { hashApiKey } from '@/lib/api-key/crypto'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
|
||||
function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'key-1',
|
||||
userId: 'user-1',
|
||||
workspaceId: null as string | null,
|
||||
type: 'personal',
|
||||
expiresAt: null as Date | null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('authenticateApiKeyFromHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetWorkspaceBillingSettings.mockReset()
|
||||
mockGetUserEntityPermissions.mockReset()
|
||||
})
|
||||
|
||||
it('returns error when no header is provided', async () => {
|
||||
const result = await authenticateApiKeyFromHeader('')
|
||||
expect(result).toEqual({ success: false, error: 'API key required' })
|
||||
expect(dbChainMockFns.where).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves when the hash lookup finds a row', async () => {
|
||||
const record = personalKeyRecord()
|
||||
dbChainMockFns.where.mockResolvedValueOnce([record])
|
||||
|
||||
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
keyId: 'key-1',
|
||||
keyType: 'personal',
|
||||
workspaceId: undefined,
|
||||
})
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
|
||||
const record = personalKeyRecord({ userId: 'other-user' })
|
||||
dbChainMockFns.where.mockResolvedValueOnce([record])
|
||||
|
||||
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid API key' })
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns invalid when the key belongs to a banned user', async () => {
|
||||
const record = personalKeyRecord({ userBanned: true })
|
||||
dbChainMockFns.where.mockResolvedValueOnce([record])
|
||||
|
||||
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid API key' })
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns invalid when the hash lookup finds no row', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([])
|
||||
|
||||
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid API key' })
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('queries by the sha256 hash of the incoming header', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()])
|
||||
|
||||
await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })
|
||||
|
||||
const [filter] = dbChainMockFns.where.mock.calls[0]
|
||||
const expected = hashApiKey('sk-sim-plain-key')
|
||||
expect(JSON.stringify(filter)).toContain(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateApiKeyLastUsed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('only writes when the stored lastUsed is missing or stale', async () => {
|
||||
await updateApiKeyLastUsed('key-1')
|
||||
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
|
||||
const [condition] = dbChainMockFns.where.mock.calls[0]
|
||||
expect(condition).toMatchObject({
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{ type: 'eq', right: 'key-1' },
|
||||
{ type: 'or', conditions: [{ type: 'isNull' }, { type: 'lt' }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows database errors instead of failing the request', async () => {
|
||||
dbChainMockFns.update.mockImplementationOnce(() => {
|
||||
throw new Error('connection lost')
|
||||
})
|
||||
|
||||
await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
|
||||
expect(serviceLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, lt, or } from 'drizzle-orm'
|
||||
import { hashApiKey } from '@/lib/api-key/crypto'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
|
||||
|
||||
const logger = createLogger('ApiKeyService')
|
||||
|
||||
export async function listApiKeys(workspaceId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: apiKeyTable.id,
|
||||
name: apiKeyTable.name,
|
||||
type: apiKeyTable.type,
|
||||
lastUsed: apiKeyTable.lastUsed,
|
||||
createdAt: apiKeyTable.createdAt,
|
||||
expiresAt: apiKeyTable.expiresAt,
|
||||
createdBy: apiKeyTable.createdBy,
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
.where(and(eq(apiKeyTable.workspaceId, workspaceId), eq(apiKeyTable.type, 'workspace')))
|
||||
.orderBy(apiKeyTable.createdAt)
|
||||
}
|
||||
|
||||
export interface ApiKeyAuthOptions {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyTypes?: ('personal' | 'workspace')[]
|
||||
}
|
||||
|
||||
export interface ApiKeyAuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
keyId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
workspaceId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const INVALID = { success: false, error: 'Invalid API key' } as const
|
||||
|
||||
interface HashCandidate {
|
||||
id: string
|
||||
userId: string
|
||||
workspaceId: string | null
|
||||
type: string
|
||||
expiresAt: Date | null
|
||||
userBanned: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API key from header with flexible filtering options.
|
||||
*
|
||||
* Looks up a single row by `sha256(apiKeyHeader)` and applies the scope /
|
||||
* expiry / permission gates. Any miss — no matching hash or a failed gate —
|
||||
* returns `INVALID`.
|
||||
*/
|
||||
export async function authenticateApiKeyFromHeader(
|
||||
apiKeyHeader: string,
|
||||
options: ApiKeyAuthOptions = {}
|
||||
): Promise<ApiKeyAuthResult> {
|
||||
if (!apiKeyHeader) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
try {
|
||||
let workspaceSettings: WorkspaceBillingSettings | null = null
|
||||
|
||||
if (options.workspaceId) {
|
||||
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
|
||||
if (!workspaceSettings) {
|
||||
return { success: false, error: 'Workspace not found' }
|
||||
}
|
||||
}
|
||||
|
||||
const keyHash = hashApiKey(apiKeyHeader)
|
||||
const rows: HashCandidate[] = await db
|
||||
.select({
|
||||
id: apiKeyTable.id,
|
||||
userId: apiKeyTable.userId,
|
||||
workspaceId: apiKeyTable.workspaceId,
|
||||
type: apiKeyTable.type,
|
||||
expiresAt: apiKeyTable.expiresAt,
|
||||
userBanned: userTable.banned,
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
|
||||
.where(eq(apiKeyTable.keyHash, keyHash))
|
||||
|
||||
if (rows.length === 0) return INVALID
|
||||
|
||||
const record = rows[0]
|
||||
const keyType = record.type as 'personal' | 'workspace'
|
||||
|
||||
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
|
||||
if (record.userBanned) return INVALID
|
||||
|
||||
if (options.userId && record.userId !== options.userId) return INVALID
|
||||
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
|
||||
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
|
||||
|
||||
if (
|
||||
options.workspaceId &&
|
||||
keyType === 'workspace' &&
|
||||
record.workspaceId !== options.workspaceId
|
||||
) {
|
||||
return INVALID
|
||||
}
|
||||
|
||||
if (options.workspaceId && keyType === 'personal') {
|
||||
if (!workspaceSettings?.allowPersonalApiKeys) return INVALID
|
||||
if (!record.userId) return INVALID
|
||||
|
||||
const permission = await getUserEntityPermissions(
|
||||
record.userId,
|
||||
'workspace',
|
||||
options.workspaceId
|
||||
)
|
||||
if (permission === null) return INVALID
|
||||
}
|
||||
|
||||
logger.debug('API key matched via hash lookup', { keyId: record.id, keyType })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: record.userId,
|
||||
keyId: record.id,
|
||||
keyType,
|
||||
workspaceId: record.workspaceId || options.workspaceId || undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error:', error)
|
||||
return { success: false, error: 'Authentication failed' }
|
||||
}
|
||||
}
|
||||
|
||||
const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Update the last used timestamp for an API key.
|
||||
*
|
||||
* `lastUsed` is display-only, so the write uses a staleness window: it only
|
||||
* fires when the stored value is older than
|
||||
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
|
||||
* the same row on every request, serializing concurrent requests behind row
|
||||
* locks. The 10-minute window matches GitLab's personal-access-token
|
||||
* last-used tracking.
|
||||
*/
|
||||
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
|
||||
try {
|
||||
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
|
||||
await db
|
||||
.update(apiKeyTable)
|
||||
.set({ lastUsed: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyTable.id, keyId),
|
||||
or(isNull(apiKeyTable.lastUsed), lt(apiKeyTable.lastUsed, staleBefore))
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error updating API key last used:', error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user