fix: paypal setup and key validation on server (#10761)

* fixes: paypal setup and key validation on server

* Renamed for legiblity

* Prefer early returns

* Update getEnabledAppsFromCredentials.ts

* Updated getEnabledAppsFromCredentials references

* Update getEnabledAppsFromCredentials.ts
This commit is contained in:
Omar López
2023-08-14 17:18:26 -07:00
committed by GitHub
parent 053f93ea0a
commit bb9a039931
11 changed files with 180 additions and 172 deletions
@@ -3,7 +3,7 @@ import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import { currencyOptions } from "@calcom/app-store/paypal/pages/setup";
import { currencyOptions } from "@calcom/app-store/paypal/lib/currencyOptions";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+25 -61
View File
@@ -12,12 +12,25 @@ class Paypal {
accessToken: string | null = null;
expiresAt: number | null = null;
constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) {
constructor(opts: { clientId: string; secretKey: string }) {
this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
this.clientId = clientId;
this.secretKey = secretKey;
this.clientId = opts.clientId;
this.secretKey = opts.secretKey;
}
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
this.getAccessToken();
return fetch(`${this.url}${endpoint}`, {
method: "get",
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
...init?.headers,
},
});
};
async getAccessToken(): Promise<void> {
if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) {
return;
@@ -64,15 +77,6 @@ class Paypal {
cancelUrl: string;
intent?: "CAPTURE" | "AUTHORIZE";
}): Promise<CreateOrderResponse> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
"PayPal-Request-Id": uuidv4(),
};
const createOrderRequestBody: CreateOrderRequestBody = {
intent,
purchase_units: [
@@ -96,9 +100,11 @@ class Paypal {
};
try {
const response = await fetch(`${this.url}/v2/checkout/orders`, {
const response = await this.fetcher("/v2/checkout/orders", {
method: "POST",
headers,
headers: {
"PayPal-Request-Id": uuidv4(),
},
body: JSON.stringify(createOrderRequestBody),
});
@@ -116,16 +122,8 @@ class Paypal {
async captureOrder(orderId: string): Promise<boolean> {
try {
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, {
const captureResult = await this.fetcher(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
headers,
});
if (captureResult.ok) {
const result = await captureResult.json();
@@ -181,14 +179,6 @@ class Paypal {
}
async createWebhook(): Promise<boolean | string> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
const body = {
url: `${WEBAPP_URL}/api/integrations/paypal/webhook`,
event_types: [
@@ -202,9 +192,8 @@ class Paypal {
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
const response = await this.fetcher(`/v1/notifications/webhooks`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
@@ -220,19 +209,8 @@ class Paypal {
}
async listWebhooks(): Promise<string[]> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks`, {
method: "GET",
headers,
});
const response = await this.fetcher(`/v1/notifications/webhooks`);
if (response.ok) {
const { webhooks } = await response.json();
@@ -251,18 +229,9 @@ class Paypal {
}
async deleteWebhook(webhookId: string): Promise<boolean> {
// Always get a new access token
await this.getAccessToken();
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
try {
const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, {
const response = await this.fetcher(`/v1/notifications/webhooks/${webhookId}`, {
method: "DELETE",
headers,
});
if (response.ok) {
@@ -286,7 +255,6 @@ class Paypal {
}
async verifyWebhook(options: WebhookEventVerifyRequest): Promise<void> {
const url = `${this.url}/v1/notifications/verify-webhook-signature`;
const parseRequest = webhookEventVerifyRequestSchema.safeParse(options);
// Webhook event should be parsable
@@ -307,12 +275,8 @@ class Paypal {
const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}";
try {
const response = await fetch(url, {
const response = await this.fetcher(`/v1/notifications/verify-webhook-signature`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
},
body: bodyToString,
});
@@ -0,0 +1,27 @@
export const currencyOptions = [
{ label: "United States dollar", value: "USD" },
{ label: "Australian dollar", value: "AUD" },
{ label: "Brazilian real 2", value: "BRL" },
{ label: "Canadian dollar", value: "CAD" },
{ label: "Chinese Renmenbi 3", value: "CNY" },
{ label: "Czech koruna", value: "CZK" },
{ label: "Danish krone", value: "DKK" },
{ label: "Euro", value: "EUR" },
{ label: "Hong Kong dollar", value: "HKD" },
{ label: "Hungarian forint 1", value: "HUF" },
{ label: "Israeli new shekel", value: "ILS" },
{ label: "Japanese yen 1", value: "JPY" },
{ label: "Malaysian ringgit 3", value: "MYR" },
{ label: "Mexican peso", value: "MXN" },
{ label: "New Taiwan dollar 1", value: "TWD" },
{ label: "New Zealand dollar", value: "NZD" },
{ label: "Norwegian krone", value: "NOK" },
{ label: "Philippine peso", value: "PHP" },
{ label: "Polish złoty", value: "PLN" },
{ label: "Pound sterling", value: "GBP" },
{ label: "Russian ruble", value: "RUB" },
{ label: "Singapore dollar", value: "SGD" },
{ label: "Swedish krona", value: "SEK" },
{ label: "Swiss franc", value: "CHF" },
{ label: "Thai baht", value: "THB" },
];
@@ -0,0 +1,47 @@
import { z } from "zod";
import type { UpdateAppCredentialsOptions } from "@calcom/trpc/server/routers/viewer/apps/updateAppCredentials.handler";
import Paypal from "./Paypal";
const schema = z.object({
credentialId: z.coerce.number(),
key: z.object({
client_id: z.string(),
secret_key: z.string(),
}),
});
const handlePaypalValidations = async ({ input }: UpdateAppCredentialsOptions) => {
const validated = schema.safeParse(input);
if (!validated.success) throw new Error("Invalid input");
const { key } = validated.data;
// Test credentials before saving
const paypalClient = new Paypal({ clientId: key.client_id, secretKey: key.secret_key });
const test = await paypalClient.test();
if (!test) throw new Error("Provided credentials failed to authenticate");
// Delete all existing webhooks
const webhooksToDelete = await paypalClient.listWebhooks();
if (webhooksToDelete) {
const promises = webhooksToDelete.map((webhook) => paypalClient.deleteWebhook(webhook));
await Promise.all(promises);
}
// Create webhook for this installation
const webhookId = await paypalClient.createWebhook();
if (!webhookId) {
// @TODO: make a button that tries to create the webhook again
console.error("Failed to create webhook", webhookId);
throw new Error("Failed to create webhook");
}
return {
client_id: key.client_id,
secret_key: key.secret_key,
webhook_id: webhookId,
};
};
export default handlePaypalValidations;
+14 -90
View File
@@ -1,57 +1,20 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Toaster } from "react-hot-toast";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, showToast, TextField } from "@calcom/ui";
export interface PayPalSetupProps {
public_key: string;
access_token: string;
currency: string;
}
export const currencyOptions = [
{ label: "United States dollar", value: "USD" },
{ label: "Australian dollar", value: "AUD" },
{ label: "Brazilian real 2", value: "BRL" },
{ label: "Canadian dollar", value: "CAD" },
{ label: "Chinese Renmenbi 3", value: "CNY" },
{ label: "Czech koruna", value: "CZK" },
{ label: "Danish krone", value: "DKK" },
{ label: "Euro", value: "EUR" },
{ label: "Hong Kong dollar", value: "HKD" },
{ label: "Hungarian forint 1", value: "HUF" },
{ label: "Israeli new shekel", value: "ILS" },
{ label: "Japanese yen 1", value: "JPY" },
{ label: "Malaysian ringgit 3", value: "MYR" },
{ label: "Mexican peso", value: "MXN" },
{ label: "New Taiwan dollar 1", value: "TWD" },
{ label: "New Zealand dollar", value: "NZD" },
{ label: "Norwegian krone", value: "NOK" },
{ label: "Philippine peso", value: "PHP" },
{ label: "Polish złoty", value: "PLN" },
{ label: "Pound sterling", value: "GBP" },
{ label: "Russian ruble", value: "RUB" },
{ label: "Singapore dollar", value: "SGD" },
{ label: "Swedish krona", value: "SEK" },
{ label: "Swiss franc", value: "CHF" },
{ label: "Thai baht", value: "THB" },
];
export default function PayPalSetup(props: PayPalSetupProps) {
export default function PayPalSetup() {
const [newClientId, setNewClientId] = useState("");
const [newSecretKey, setNewSecretKey] = useState("");
const router = useRouter();
const { t } = useLocale();
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" });
const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment");
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false];
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "paypal" });
const [paypalPaymentAppCredentials] = integrations.data?.items || [];
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
onSuccess: () => {
@@ -63,48 +26,6 @@ export default function PayPalSetup(props: PayPalSetupProps) {
},
});
const saveKeys = async (key: { clientId: string; secretKey: string }) => {
if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) {
return false;
}
if (typeof credentialId !== "number") {
return;
}
// Test credentials before saving
const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey });
const test = await paypalClient.test();
if (!test) {
return false;
}
// Delete all existing webhooks
const webhooksToDelete = await paypalClient.listWebhooks();
if (webhooksToDelete) {
for (const webhook of webhooksToDelete) {
await paypalClient.deleteWebhook(webhook);
}
}
// Create webhook for this installation
const webhookId = await paypalClient.createWebhook();
if (!webhookId) {
// @TODO: make a button that tries to create the webhook again
console.log("Failed to create webhook", webhookId);
return false;
}
saveKeysMutation.mutate({
credentialId,
key: {
client_id: key.clientId,
secret_key: key.secretKey,
webhook_id: webhookId,
},
});
return true;
};
if (integrations.isLoading) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
@@ -144,12 +65,15 @@ export default function PayPalSetup(props: PayPalSetupProps) {
<div className="mt-5 flex flex-row justify-end">
<Button
color="secondary"
onClick={() =>
saveKeys({
clientId: newClientId,
secretKey: newSecretKey,
})
}>
onClick={() => {
saveKeysMutation.mutate({
credentialId,
key: {
client_id: newClientId,
secret_key: newSecretKey,
},
});
}}>
{t("save")}
</Button>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@ import type { TFunction } from "next-i18next";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import { defaultVideoAppCategories } from "@calcom/app-store/utils";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import { prisma } from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
@@ -77,7 +77,7 @@ export async function getLocationGroupedOptions(
},
});
const integrations = await getEnabledApps(credentials, true);
const integrations = await getEnabledAppsFromCredentials(credentials, { filterOnCredentials: true });
integrations.forEach((app) => {
// All apps that are labeled as a locationOption are video apps.
@@ -1,18 +1,27 @@
import getApps from "@calcom/app-store/utils";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@prisma/client";
import type { Prisma } from ".prisma/client";
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
import getApps from "@calcom/app-store/utils";
import { prisma } from "@calcom/prisma";
type EnabledApp = ReturnType<typeof getApps>[number] & { enabled: boolean };
/**
*
* @param credentials - Can be user or team credentials
* @param filterOnCredentials - Only include apps where credentials are present
* @param options
* @param options.where Aditional where conditions to filter out apps
* @param options.filterOnCredentials - Only include apps where credentials are present
* @returns A list of enabled app metadata & credentials tied to them
*/
const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterOnCredentials?: boolean) => {
const getEnabledAppsFromCredentials = async (
credentials: CredentialDataWithTeamName[],
options?: {
where?: Prisma.AppWhereInput;
filterOnCredentials?: boolean;
}
) => {
const { where: _where = {}, filterOnCredentials = false } = options || {};
const filterOnIds = {
credentials: {
some: {
@@ -33,11 +42,13 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO
if (teamIds.length) filterOnIds.credentials.some.OR.push({ teamId: { in: teamIds } });
}
const where: Prisma.AppWhereInput = {
enabled: true,
..._where,
...(filterOnIds.credentials.some.OR.length && filterOnIds),
};
const enabledApps = await prisma.app.findMany({
where: {
enabled: true,
...(filterOnIds.credentials.some.OR.length && filterOnIds),
},
where,
select: { slug: true, enabled: true },
});
const apps = getApps(credentials, filterOnCredentials);
@@ -52,4 +63,4 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO
return filteredApps;
};
export default getEnabledApps;
export default getEnabledAppsFromCredentials;
@@ -1,5 +1,7 @@
import type { Credential, Prisma } from "@prisma/client";
import type { CredentialOwner } from "@calcom/app-store/types";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import prisma from "@calcom/prisma";
@@ -7,7 +9,6 @@ import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TIntegrationsInputSchema } from "./integrations.schema";
import type { Prisma, Credential } from ".prisma/client";
type IntegrationsOptions = {
ctx: {
@@ -44,6 +45,8 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
extendsFeature,
teamId,
sortByMostPopular,
categories,
appId,
} = input;
let credentials = await getUsersCredentials(user.id);
let userTeams: TeamQuery[] = [];
@@ -116,7 +119,10 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
}
}
const enabledApps = await getEnabledApps(credentials, onlyInstalled);
const enabledApps = await getEnabledAppsFromCredentials(credentials, {
filterOnCredentials: onlyInstalled,
...(appId ? { where: { slug: appId } } : {}),
});
//TODO: Refactor this to pick up only needed fields and prevent more leaking
let apps = enabledApps.map(
({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => {
@@ -1,5 +1,7 @@
import { z } from "zod";
import { AppCategories } from "@calcom/prisma/enums";
export const ZIntegrationsInputSchema = z.object({
variant: z.string().optional(),
exclude: z.array(z.string()).optional(),
@@ -8,6 +10,8 @@ export const ZIntegrationsInputSchema = z.object({
extendsFeature: z.literal("EventType").optional(),
teamId: z.union([z.number(), z.null()]).optional(),
sortByMostPopular: z.boolean().optional(),
categories: z.nativeEnum(AppCategories).array().optional(),
appId: z.string().optional(),
});
export type TIntegrationsInputSchema = z.infer<typeof ZIntegrationsInputSchema>;
@@ -5,8 +5,9 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema";
import { handleCustomValidations } from "./updateAppCredentials.validator";
type UpdateAppCredentialsOptions = {
export type UpdateAppCredentialsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
@@ -16,8 +17,6 @@ type UpdateAppCredentialsOptions = {
export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCredentialsOptions) => {
const { user } = ctx;
const { key } = input;
// Find user credential
const credential = await prisma.credential.findFirst({
where: {
@@ -33,6 +32,8 @@ export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCrede
});
}
const validatedKeys = await handleCustomValidations({ ctx, input, appId: credential.appId || "" });
const updated = await prisma.credential.update({
where: {
id: credential.id,
@@ -40,7 +41,7 @@ export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCrede
data: {
key: {
...(credential.key as Prisma.JsonObject),
...(key as Prisma.JsonObject),
...(validatedKeys as Prisma.JsonObject),
},
},
});
@@ -0,0 +1,24 @@
import { TRPCError } from "@trpc/server";
import type { UpdateAppCredentialsOptions } from "./updateAppCredentials.handler";
const validators = {
paypal: () => import("@calcom/paypal/lib/updateAppCredentials.validator"),
};
export const handleCustomValidations = async ({
ctx,
input,
appId,
}: UpdateAppCredentialsOptions & { appId: string }) => {
const { key } = input;
const validatorGetter = validators[appId as keyof typeof validators];
// If no validator is found, return the key as is
if (!validatorGetter) return key;
try {
const validator = (await validatorGetter()).default;
return await validator({ input, ctx });
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
};