Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/apiService.ts
T

329 lines
11 KiB
TypeScript

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<ApiAccessValidation> {
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<ApiAccessValidation> {
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<WorkflowApiAccessValidation> {
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);
}