import { timingSafeEqual } from "node:crypto"; import { createUniqueLegacyTenantIpAddress, findLegacyTenantByIdOrSlug, getLegacyTenantMembershipsForUser, } from "@toolctrl/db/accounts.server"; import { createLegacyApiKeyLog, findLegacyApiKeyByKey, setLegacyApiKeyLogStatus as setApiKeyLogStatus, type LegacyApiKeySimple, } from "@toolctrl/db/api-keys.server"; import type { ApiKeyLog } from "@toolctrl/db/prisma-browser"; import { findLegacyUserWithoutPasswordById } from "@toolctrl/db/users.server"; import { Params } from "react-router"; import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures"; import { getActiveTenantSubscriptions, getPlanFeatureUsage } from "./.server/subscriptionService"; import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto"; import { getClientIPAddress } from "../server/IpUtils"; import jwt from "jsonwebtoken"; import NotificationService from "~/modules/notifications/services/.server/NotificationService"; import { getOpenInvoices } from "../stripe.server"; import { getTranslations } from "~/locale/i18next.server"; import { rateLimitByKey } from "./rateLimitService"; import { cachified } from "../cache.server"; import { loadApiConfig, loadRequiredWebConfig } from "@toolctrl/config"; import { absoluteAppPath } from "../app/CrossHostUrls"; import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.server"; type ApiKeyLogScope = { id: string; tenantId: string }; async function setApiError(request: Request, params: Params, error: string, status: number, apiKeyLogScope?: ApiKeyLogScope) { if (apiKeyLogScope) { await setApiKeyLogStatus(apiKeyLogScope, { error, status, startTime: null, }); } else { await createApiKeyLog(request, { apiKeyId: null, tenantId: null, endpoint: new URL(request.url).pathname, error, status, }); } throw Object.assign(new Error(error), { status, apiKeyLog: apiKeyLogScope, }); } function getRequiredJwtSecret() { const jwtSecret = loadRequiredWebConfig().jwtSecret; if (!jwtSecret) { throw new Error("JWT_SECRET is required for API authorization."); } return jwtSecret; } async function createApiKeyLog( request: Request, data: { tenantId: string | null; apiKeyId: string | null; endpoint: string; error?: string; status?: number; alias?: string; tenantName?: string; }, ) { return createLegacyApiKeyLog({ tenantId: data.tenantId, apiKeyId: data.apiKeyId, endpoint: data.endpoint, error: data.error, status: data.status, alias: data.alias, tenantName: data.tenantName, ip: getClientIPAddress(request) ?? "", method: request.method, params: serializeApiLogSearchParams(request), }); } async function getApiKeyFromHeader(key: string) { return cachified({ key: getApiKeyLookupCacheKey(key), ttl: 1000 * 60 * 60, getFreshValue: () => { const { config } = loadApiConfig(); return findLegacyApiKeyByKey(key, undefined, config.jwtSecret, { allowPlaintextFallback: config.apiKeys.allowLegacyPlaintextFallback, }); }, }); } export function getServiceAccessTokenFromRequest(request: Request) { const authorization = request.headers.get("Authorization")?.trim() ?? ""; if (/^Bearer\s+/i.test(authorization)) { return authorization.replace(/^Bearer\s+/i, "").trim(); } return request.headers.get("X-Api-Key")?.trim() ?? ""; } export function hasValidServiceAccessToken(request: Request) { const apiAccessToken = loadRequiredWebConfig().apiAccessToken; return typeof apiAccessToken === "string" && apiAccessToken.length > 0 && timingSafeStringEqual(getServiceAccessTokenFromRequest(request), apiAccessToken); } function timingSafeStringEqual(left: string, right: string) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); if (leftBuffer.byteLength !== rightBuffer.byteLength) { return false; } return timingSafeEqual(leftBuffer, rightBuffer); } function getServiceTenantScopeFromRequest(request: Request) { const scopes = getTenantScopeOverridesFromRequest(request); const distinctValues = new Set(scopes); if (distinctValues.size > 1) { throw Error("Conflicting tenant scope values"); } return scopes[0] ?? null; } function getTenantScopeOverridesFromRequest(request: Request) { const searchParams = new URL(request.url).searchParams; return [ searchParams.get("tenantId"), request.headers.get("X-Tenant-Id"), request.headers.get("X-Account-Id"), ] .map((value) => value?.trim() ?? "") .filter((value) => value.length > 0); } export type ApiAccessValidation = { userId?: string; tenant: { id: string; name: string; slug: string; deactivatedReason: string | null; } | null; tenantApiKey?: { apiKey: LegacyApiKeySimple; apiKeyLog: ApiKeyLog; usage: PlanFeatureUsageDto | undefined; }; }; export async function validateApiKey(request: Request, params: Params): Promise { const authorization = request.headers.get("Authorization"); if (authorization && authorization.startsWith("Bearer ")) { const token = authorization.split(" ")[1]; let userId = ""; try { const decoded: any = jwt.verify(token, getRequiredJwtSecret()); userId = decoded.userId; } catch (e: any) { throw Error("Invalid authorization token: " + e.message); } const user = await findLegacyUserWithoutPasswordById(userId); if (!user) { throw Error("Unauthorized"); } let tenantId = request.headers.get("X-Account-Id") ?? request.headers.get("X-Tenant-Id"); if (!tenantId) { if (!user.admin) { throw Error("No X-Account-Id provided"); } else { tenantId = null; } } if (tenantId !== null) { const userTenants = await getLegacyTenantMembershipsForUser(user.id); if (!userTenants.find((t) => t.id === tenantId || t.slug === tenantId)) { throw Error("Unauthorized"); } } const tenant = await findLegacyTenantByIdOrSlug(tenantId ?? ""); return { userId, tenant: tenantId === null ? null : tenant, }; } const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? ""; if (!apiKeyFromHeaders) { throw Error("No X-API-Key header or Authorization token provided"); } const apiAccessToken = loadRequiredWebConfig().apiAccessToken; if (apiAccessToken && apiKeyFromHeaders === apiAccessToken) { const tenantId = getServiceTenantScopeFromRequest(request); if (!tenantId) { return { tenant: null }; } const tenant = await findLegacyTenantByIdOrSlug(tenantId); if (!tenant) { throw Error("Account not found: " + tenantId); } return { tenant }; } if (getTenantScopeOverridesFromRequest(request).length > 0) { throw Error("You cannot use tenantId, X-Tenant-Id, or X-Account-Id with a stored API key"); } return validateTenantApiKey(request, params); } export async function validateTenantApiKey(request: Request, params: Params): Promise { const { t } = await getTranslations(request); const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? ""; const apiKey = await getApiKeyFromHeader(apiKeyFromHeaders); if (!apiKey) { throw await setApiError(request, params, "Invalid API Key", 401); } const tenantSubscription = await getActiveTenantSubscriptions(apiKey.tenantId); const rateLimitResult = await rateLimitByKey({ apiKey: apiKey.id, tenantId: apiKey.tenantId, tenantSubscription, }); const apiKeyLog = await createApiKeyLog(request, { apiKeyId: apiKey.id, tenantId: apiKey.tenantId, endpoint: new URL(request.url).pathname, }); if (rateLimitResult.error) { throw await setApiError(request, params, "Rate limit exceeded: " + rateLimitResult.error, 429, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } createUniqueLegacyTenantIpAddress({ ip: getClientIPAddress(request) ?? "Unknown", fromUrl: new URL(request.url).pathname, tenantId: apiKey.tenantId, apiKeyId: apiKey.id, }); if (apiKey.tenant.deactivatedReason) { throw await setApiError(request, params, "Account deactivated: " + apiKey.tenant.deactivatedReason, 403, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } if (!apiKey.active) { throw await setApiError(request, params, "Inactive API Key", 400, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } if (apiKey.expires && apiKey?.expires < new Date()) { throw await setApiError(request, params, "Expired API Key", 400, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } const usage = await getPlanFeatureUsage(apiKey.tenantId, DefaultFeatures.API, tenantSubscription); if (usage && !usage.enabled) { await NotificationService.sendToRoles({ channel: "admin-users", tenantId: null, notification: { message: `${apiKey.tenant.name} (${apiKey.tenant.slug}) API error. ${t(usage.message)}`, action: { url: absoluteAppPath(loadRequiredWebConfig().routing, apiKey.tenant.slug, "settings/api/keys"), }, }, }); throw await setApiError(request, params, t(usage.message), 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } if (tenantSubscription && tenantSubscription.stripeCustomerId) { const openInvoices = await getOpenInvoices(tenantSubscription.stripeCustomerId); if (openInvoices.data.length > 0) { const invoicesDescription = openInvoices.data.map((invoice) => `Invoice #${invoice.number} - ${invoice.total} ${invoice.currency}`).join("\n"); await NotificationService.sendToRoles({ channel: "admin-users", tenantId: null, notification: { message: `${apiKey.tenant.name} (${apiKey.tenant.slug}) API error. You have open invoices: ${invoicesDescription}`, action: { url: absoluteAppPath(loadRequiredWebConfig().routing, apiKey.tenant.slug, "settings/api/keys"), }, }, }); throw await setApiError(request, params, "You have open invoices: " + invoicesDescription, 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } } return { tenant: apiKey.tenant, tenantApiKey: { apiKey, apiKeyLog, usage, }, }; } export type WorkflowApiAccessValidation = { tenant: ApiAccessValidation["tenant"]; userId?: string; apiKeyLog?: ApiKeyLog | ApiKeyLogScope; usage?: PlanFeatureUsageDto; }; export async function getWorkflowApiKeyFromRequest(request: Request, params: Params): Promise { const apiAccessValidation = await validateApiKey(request, params); return { tenant: apiAccessValidation.tenant, userId: apiAccessValidation.userId, apiKeyLog: apiAccessValidation.tenantApiKey?.apiKeyLog, usage: apiAccessValidation.tenantApiKey?.usage, }; } export async function setWorkflowApiKeyLogStatus( apiAccessValidation: WorkflowApiAccessValidation | undefined, data: { error?: string; status: number; startTime: number }, ) { if (!apiAccessValidation?.apiKeyLog) { return; } const tenantId = apiAccessValidation.apiKeyLog.tenantId ?? apiAccessValidation.tenant?.id; if (!tenantId) { throw new Error(`API key log ${apiAccessValidation.apiKeyLog.id} cannot be updated without tenant scope`); } await setApiKeyLogStatus({ id: apiAccessValidation.apiKeyLog.id, tenantId }, data); }