mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
Fixes cf-998 # Pull Request Checklist ## Description - [ ] **Description of PR**: Clear and concise description of what this PR accomplishes - [ ] **Breaking Changes**: Document any breaking changes (if applicable) - [ ] **Related Issues**: Link to any related issues or tickets ## Testing - [ ] **Test cases Attached**: All relevant test cases have been added/updated - [ ] **Manual Testing**: Manual testing completed for the changes ## Monitoring & Debugging - [ ] **Logging in place**: Appropriate logging has been added for debugging user issues - [ ] **Sentry will be able to catch errors**: Error handling ensures Sentry can capture and report errors - [ ] **Avoid Dev based/Prisma logging**: No development-only or Prisma-specific logging in production code ## Configuration - [ ] **Env variables newly added**: Any new environment variables are documented in .env.example file or mentioned in description --- ## Additional Notes <!-- Add any additional context, screenshots, or notes for reviewers here -->
273 lines
8 KiB
TypeScript
273 lines
8 KiB
TypeScript
"use server"
|
|
import util from "util"
|
|
import { createHash, type Hash, randomBytes } from "crypto"
|
|
import { prisma } from "./prisma-client"
|
|
import { organizationMemberRepository } from "./repositories/organization-member-repository"
|
|
|
|
async function generateRandomAPIKey(): Promise<string> {
|
|
const randomBytesAsync = util.promisify(randomBytes)
|
|
const token = (await randomBytesAsync(48)).toString("base64url")
|
|
return "cf-" + token
|
|
}
|
|
export const VS_CODE_KEY_NAME = "vsc-ext-3Yg1NRCS@6"
|
|
export async function hashApiKey(token: string) {
|
|
// TODO: Consider stripping the cf- prefix from the token before hashing. This would reduce the chance of
|
|
// a collision, and would also make it harder to brute force the token.
|
|
const hash: Hash = createHash("sha384")
|
|
hash.update(token)
|
|
return hash.digest("base64url")
|
|
}
|
|
|
|
export async function genAndStoreAPITokenHash(
|
|
keyName: string,
|
|
userId: string,
|
|
organizationId?: string,
|
|
): Promise<string> {
|
|
// The idea here is to not store the API Keys in the database in plaintext. Because if we do that, then
|
|
// the keys are vulnerable to internal and external database breaches. Instead, we store the hash
|
|
// of the key in the database. When a user wants to use the key, they send the plaintext key to the server,
|
|
// which hashes it and compares it to the hash in the database. If they match, then the key is valid.
|
|
// The reason to not use bcrypt is that bcrypt is designed to be slow, which is good for passwords, but
|
|
// not good for API keys. Also with salting, one can't simply search for a key in the DB.
|
|
// Instead, we use SHA-384, which is designed to be fast. Rainbow table attacks are mitigated by using a long
|
|
// random key, for which rainbow tables become unfeasible and the attacker would have to brute force the key.
|
|
// The reason to use SHA-384 instead of SHA-256 is that SHA-384 has a longer output, which makes it much harder
|
|
// to brute force.
|
|
|
|
const token: string = await generateRandomAPIKey()
|
|
const hashedToken = await hashApiKey(token)
|
|
await prisma.cf_api_keys.create({
|
|
data: {
|
|
key: hashedToken,
|
|
user_id: userId,
|
|
organization_id: organizationId,
|
|
suffix: token.slice(-4),
|
|
name: keyName,
|
|
},
|
|
})
|
|
return token
|
|
}
|
|
export async function genAndStoreAPITokenHashForVSC(userId: string, organizationId?: string) {
|
|
const token: string = await generateRandomAPIKey()
|
|
const hashedToken = await hashApiKey(token)
|
|
const data = await prisma.cf_api_keys.findFirst({
|
|
where: {
|
|
user_id: userId,
|
|
name: VS_CODE_KEY_NAME,
|
|
},
|
|
})
|
|
if (data) {
|
|
await prisma.cf_api_keys.update({
|
|
where: {
|
|
id: data.id,
|
|
},
|
|
data: {
|
|
key: hashedToken,
|
|
suffix: token.slice(-4),
|
|
organization_id: organizationId ?? null,
|
|
},
|
|
})
|
|
} else {
|
|
await prisma.cf_api_keys.create({
|
|
data: {
|
|
key: hashedToken,
|
|
user_id: userId,
|
|
organization_id: organizationId ?? null,
|
|
suffix: token.slice(-4),
|
|
name: VS_CODE_KEY_NAME,
|
|
},
|
|
})
|
|
}
|
|
|
|
return token
|
|
}
|
|
export interface ApiKeyInfo {
|
|
user_id: string | null
|
|
organization_id: string | null
|
|
}
|
|
|
|
export async function userForAPIKey(key: string): Promise<null | string> {
|
|
// TODO: Add a rate limiter to prevent brute force attacks.
|
|
const hashedToken = await hashApiKey(key)
|
|
try {
|
|
// Find the API key - only fetch what we need
|
|
const apiKey = await prisma.cf_api_keys.findUnique({
|
|
where: { key: hashedToken },
|
|
select: {
|
|
user_id: true,
|
|
},
|
|
})
|
|
|
|
if (apiKey == null) {
|
|
console.log(`[Auth] API key not found (suffix: ${key.slice(-4)})`)
|
|
return null
|
|
}
|
|
|
|
// Update last_used timestamp
|
|
await prisma.cf_api_keys.update({
|
|
where: { key: hashedToken },
|
|
data: { last_used: new Date() },
|
|
})
|
|
|
|
console.log(`[Auth] API key authenticated successfully`)
|
|
return apiKey.user_id
|
|
} catch (e) {
|
|
console.error(`[Auth] Error in userForAPIKey:`, e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get full API key info including organization_id
|
|
*/
|
|
export async function getApiKeyInfo(key: string): Promise<ApiKeyInfo | null> {
|
|
const hashedToken = await hashApiKey(key)
|
|
try {
|
|
const apiKey = await prisma.cf_api_keys.findUnique({
|
|
where: { key: hashedToken },
|
|
select: {
|
|
user_id: true,
|
|
organization_id: true,
|
|
},
|
|
})
|
|
|
|
if (apiKey == null) {
|
|
console.log(`[Auth] API key not found (suffix: ${key.slice(-4)})`)
|
|
return null
|
|
}
|
|
|
|
// Update last_used timestamp
|
|
await prisma.cf_api_keys.update({
|
|
where: { key: hashedToken },
|
|
data: { last_used: new Date() },
|
|
})
|
|
|
|
return {
|
|
user_id: apiKey.user_id,
|
|
organization_id: apiKey.organization_id,
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Auth] Error in getApiKeyInfo:`, e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get effective privacy mode for an API key.
|
|
* Privacy mode is enabled if:
|
|
* - The API key belongs to an organization with subscription AND org privacy_mode is enabled, OR
|
|
* - The user is on a paid tier (not free) AND user privacy_mode is enabled
|
|
*
|
|
* When privacy mode is enabled, data is fetched from GitHub instead of being stored.
|
|
* When privacy mode is disabled, data is stored as plaintext.
|
|
*/
|
|
export async function getEffectivePrivacyMode(
|
|
userId: string,
|
|
organizationId: string | null,
|
|
): Promise<boolean> {
|
|
// If API key has an organization, check if org has subscription AND privacy_mode is enabled
|
|
if (organizationId) {
|
|
const org = await prisma.organizations.findUnique({
|
|
where: { id: organizationId },
|
|
select: {
|
|
subscription: true,
|
|
privacy_mode: true,
|
|
},
|
|
})
|
|
|
|
// If org has subscription, use org's privacy_mode setting
|
|
if (org?.subscription) {
|
|
if (org.privacy_mode) {
|
|
console.log(`[Privacy] Organization has subscription and privacy mode enabled`)
|
|
return true
|
|
} else {
|
|
console.log(`[Privacy] Organization has subscription but privacy mode is disabled`)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if user is on a paid tier AND has privacy mode enabled
|
|
const [subscription, user] = await Promise.all([
|
|
prisma.subscriptions.findUnique({
|
|
where: { user_id: userId },
|
|
select: { plan_type: true },
|
|
}),
|
|
prisma.users.findUnique({
|
|
where: { user_id: userId },
|
|
select: { privacy_mode: true },
|
|
}),
|
|
])
|
|
|
|
const isPaidUser = subscription?.plan_type && subscription.plan_type.toLowerCase() !== "free"
|
|
|
|
if (isPaidUser && user?.privacy_mode) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export async function deleteAPIKeyById(id: number, userId: string) {
|
|
return prisma.cf_api_keys.delete({
|
|
where: {
|
|
id,
|
|
user_id: userId,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete all API keys created by a user for a specific organization.
|
|
* This should be called when a user is removed from an organization.
|
|
*/
|
|
export async function deleteOrganizationMemberApiKeys(
|
|
userId: string,
|
|
organizationId: string,
|
|
) {
|
|
return prisma.cf_api_keys.deleteMany({
|
|
where: {
|
|
user_id: userId,
|
|
organization_id: organizationId,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Check whether a user has fewer than 30 API tokens.
|
|
*
|
|
*/
|
|
|
|
export async function canInsertMoreTokens(userId: string): Promise<boolean> {
|
|
const tokenCount = await prisma.cf_api_keys.count({
|
|
where: {
|
|
user_id: userId,
|
|
name: { not: VS_CODE_KEY_NAME },
|
|
},
|
|
})
|
|
return tokenCount < 30
|
|
}
|
|
|
|
/**
|
|
* Wrapper function to enforce token limit consistently.
|
|
* Call this instead of genAndStoreAPITokenHash if you want to prevent generating tokens
|
|
* for users who already have 30 or more.
|
|
*/
|
|
export async function safeGenAndStoreAPITokenHash(
|
|
keyName: string,
|
|
userId: string,
|
|
organizationId?: string,
|
|
): Promise<string> {
|
|
if (!(await canInsertMoreTokens(userId))) {
|
|
throw new Error("Token limit exceeded")
|
|
}
|
|
|
|
if (organizationId) {
|
|
const isMember = await organizationMemberRepository.isMember(organizationId, userId)
|
|
if (!isMember) {
|
|
throw new Error("User is not a member of the specified organization")
|
|
}
|
|
}
|
|
|
|
return genAndStoreAPITokenHash(keyName, userId, organizationId)
|
|
}
|