import { timingSafeEqual } from "node:crypto"; import { createUniqueLegacyTenantIpAddress, findLegacyTenantByIdOrSlug } from "@hivegrid/db/accounts.server"; import { createLegacyApiKeyLog, findLegacyApiKeyByKeyWithRlsBootstrap, setLegacyApiKeyLogStatus as setApiKeyLogStatus, type LegacyApiKeySimple, } from "@hivegrid/db/api-keys.server"; import type { ApiKeyLog } from "@hivegrid/db/prisma-browser"; import { runWithRlsContext } from "@hivegrid/db/rls-context.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 { getTrustedClientIpOrNull } from "../server/IpUtils"; import NotificationService from "~/modules/notifications/services/.server/NotificationService"; import { getTranslations } from "~/locale/i18next.server"; import { rateLimitByKey } from "./rateLimitService"; import { loadApiConfig, loadRequiredWebConfig } from "@hivegrid/config"; import { absoluteAppPath } from "../app/CrossHostUrls"; import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.server"; import { loadCachedApiKeySnapshot } from "../api/api-key-auth-cache.server"; import { loadTenantSecuritySnapshot } from "../db/tenant-cache.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, }); } throw Object.assign(new Error(error), { status, apiKeyLog: apiKeyLogScope, }); } 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: getTrustedClientIpOrNull(request) ?? "", method: request.method, params: serializeApiLogSearchParams(request), }); } async function getApiKeyFromHeader(key: string) { return loadCachedApiKeySnapshot(getApiKeyLookupCacheKey(key), () => { const { config } = loadApiConfig(); return findLegacyApiKeyByKeyWithRlsBootstrap(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")?.trim() ?? ""; const hasBearerCredential = /^Bearer\s+/i.test(authorization); if (hasBearerCredential && !hasValidServiceAccessToken(request)) { // User/API JWTs belong to the dedicated API runtime. The web runtime only // accepts its exact service credential as Bearer auth; stored tenant keys // must use X-Api-Key so an HS256 token can never be mistaken for a session. throw Error("Bearer authentication is only supported for service access tokens"); } const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? ""; if (!apiKeyFromHeaders && !hasBearerCredential) { throw Error("No X-API-Key header or Authorization token provided"); } if (hasValidServiceAccessToken(request)) { const tenantId = getServiceTenantScopeFromRequest(request); if (!tenantId) { return { tenant: null }; } const tenant = await runWithRlsContext({ tenantId }, () => loadTenantSecuritySnapshot(tenantId, async () => { const current = await findLegacyTenantByIdOrSlug(tenantId); return current ? { id: current.id, name: current.name, slug: current.slug, deactivatedReason: current.deactivatedReason } : null; }) ); 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); } return runWithRlsContext({ tenantId: apiKey.tenantId, userId: apiKey.createdByUserId }, async () => { const tenant = await loadTenantSecuritySnapshot(apiKey.tenantId, async () => { const current = await findLegacyTenantByIdOrSlug(apiKey.tenantId); return current ? { id: current.id, name: current.name, slug: current.slug, deactivatedReason: current.deactivatedReason } : null; }); if (!tenant) { throw await setApiError(request, params, "Account not found", 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: getTrustedClientIpOrNull(request) ?? "Unknown", fromUrl: new URL(request.url).pathname, tenantId: apiKey.tenantId, apiKeyId: apiKey.id, }); if (tenant.deactivatedReason) { throw await setApiError(request, params, "Account deactivated: " + 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: `${tenant.name} (${tenant.slug}) API error. ${t(usage.message)}`, action: { url: absoluteAppPath(loadRequiredWebConfig().routing, tenant.slug, "api/keys"), }, }, }); throw await setApiError(request, params, t(usage.message), 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId }); } return { userId: apiKey.createdByUserId, tenant, tenantApiKey: { apiKey: { ...apiKey, tenant }, 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 runWithRlsContext({ tenantId, userId: apiAccessValidation.userId }, () => setApiKeyLogStatus({ id: apiAccessValidation.apiKeyLog!.id, tenantId }, data) ); } export function runWithWorkflowApiRlsContext(apiAccessValidation: WorkflowApiAccessValidation, callback: () => T): T { const tenantId = apiAccessValidation.tenant?.id; if (!tenantId) { throw new Error("Workflow API tenant context is required"); } return runWithRlsContext({ tenantId, userId: apiAccessValidation.userId }, callback); }