d25d482dc2
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
213 lines
6.4 KiB
TypeScript
213 lines
6.4 KiB
TypeScript
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 }
|
|
}
|