From 68bd877c5bfa01d06d5a9d298fc46583e98f9047 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 28 Sep 2023 21:41:28 +0200 Subject: [PATCH] feat: OAuth provider for Zapier (#11465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alex van Andel Co-authored-by: sajanlamsal Co-authored-by: CarinaWolli Co-authored-by: alannnc Co-authored-by: Leo Giovanetti Co-authored-by: Peer Richelsen Co-authored-by: Hariom Balhara Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Nitin Panghal Co-authored-by: Omar López Co-authored-by: Peer Richelsen Co-authored-by: zomars Co-authored-by: Shivam Kalra Co-authored-by: Richard Poelderl Co-authored-by: Crowdin Bot Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Nafees Nazik <84864519+G3root@users.noreply.github.com> Co-authored-by: Chiranjeev Vishnoi <66114276+Chiranjeev-droid@users.noreply.github.com> Co-authored-by: Denzil Samuel <71846487+samueldenzil@users.noreply.github.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: nitinpanghal <43965732+nitinpanghal@users.noreply.github.com> Co-authored-by: Ahmad <57593864+Ahmadkashif@users.noreply.github.com> Co-authored-by: Annlee Fores Co-authored-by: Keith Williams Co-authored-by: Vijay --- apps/web/pages/api/auth/oauth/me.ts | 14 + apps/web/pages/api/auth/oauth/refreshToken.ts | 68 ++ apps/web/pages/api/auth/oauth/token.ts | 104 +++ apps/web/pages/api/trpc/oAuth/[trpc].ts | 4 + apps/web/pages/auth/oauth2/authorize.tsx | 179 +++++ apps/web/pages/settings/admin/oAuth/index.tsx | 11 + .../pages/settings/admin/oAuth/oAuthView.tsx | 151 ++++ apps/web/playwright/oauth-provider.e2e.ts | 227 ++++++ apps/web/public/static/locales/en/common.json | 18 + package.json | 1 + .../api/subscriptions/addSubscription.ts | 20 +- .../api/subscriptions/deleteSubscription.ts | 18 +- .../zapier/api/subscriptions/listBookings.ts | 33 +- .../zapier/lib/validateAccountOrApiKey.ts | 19 + .../playwright/tests/embed-pages.e2e.ts | 1 + .../embed-react/test/packaged/api.test.ts | 12 +- .../features/auth/lib/oAuthAuthorization.ts | 55 ++ .../settings/layouts/SettingsLayout.tsx | 1 + .../features/webhooks/lib/scheduleTrigger.ts | 99 ++- .../migration.sql | 38 + packages/prisma/schema.prisma | 29 + packages/trpc/react/trpc.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../server/routers/viewer/oAuth/_router.tsx | 68 ++ .../routers/viewer/oAuth/addClient.handler.ts | 38 + .../routers/viewer/oAuth/addClient.schema.ts | 9 + .../viewer/oAuth/generateAuthCode.handler.ts | 78 ++ .../viewer/oAuth/generateAuthCode.schema.ts | 9 + .../routers/viewer/oAuth/getClient.handler.ts | 24 + .../routers/viewer/oAuth/getClient.schema.ts | 7 + yarn.lock | 754 +++++++++++++++--- 31 files changed, 1896 insertions(+), 196 deletions(-) create mode 100644 apps/web/pages/api/auth/oauth/me.ts create mode 100644 apps/web/pages/api/auth/oauth/refreshToken.ts create mode 100644 apps/web/pages/api/auth/oauth/token.ts create mode 100644 apps/web/pages/api/trpc/oAuth/[trpc].ts create mode 100644 apps/web/pages/auth/oauth2/authorize.tsx create mode 100644 apps/web/pages/settings/admin/oAuth/index.tsx create mode 100644 apps/web/pages/settings/admin/oAuth/oAuthView.tsx create mode 100644 apps/web/playwright/oauth-provider.e2e.ts create mode 100644 packages/app-store/zapier/lib/validateAccountOrApiKey.ts create mode 100644 packages/features/auth/lib/oAuthAuthorization.ts create mode 100644 packages/prisma/migrations/20230920175742_add_oauth_model/migration.sql create mode 100644 packages/trpc/server/routers/viewer/oAuth/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/addClient.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/generateAuthCode.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/getClient.schema.ts diff --git a/apps/web/pages/api/auth/oauth/me.ts b/apps/web/pages/api/auth/oauth/me.ts new file mode 100644 index 0000000000..81aaf6e101 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/me.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const requriedScopes = ["READ_PROFILE"]; + + const account = await isAuthorized(req, requriedScopes); + + if (!account) { + return res.status(401).json({ message: "Unauthorized" }); + } + return res.status(201).json({ username: account.name }); +} diff --git a/apps/web/pages/api/auth/oauth/refreshToken.ts b/apps/web/pages/api/auth/oauth/refreshToken.ts new file mode 100644 index 0000000000..a302cc8bf6 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/refreshToken.ts @@ -0,0 +1,68 @@ +import jwt from "jsonwebtoken"; +import type { NextApiRequest, NextApiResponse } from "next"; +import type { OAuthTokenPayload } from "pages/api/auth/oauth/token"; + +import prisma from "@calcom/prisma"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(405).json({ message: "Invalid method" }); + return; + } + + const refreshToken = req.headers.authorization?.split(" ")[1] || ""; + + const { client_id, client_secret, grant_type } = req.body; + + if (grant_type !== "refresh_token") { + res.status(400).json({ message: "grant type invalid" }); + return; + } + + const [hashedSecret] = generateSecret(client_secret); + + const client = await prisma.oAuthClient.findFirst({ + where: { + clientId: client_id, + clientSecret: hashedSecret, + }, + select: { + redirectUri: true, + }, + }); + + if (!client) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + + let decodedRefreshToken: OAuthTokenPayload; + + try { + decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload; + } catch { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const payload: OAuthTokenPayload = { + userId: decodedRefreshToken.userId, + scope: decodedRefreshToken.scope, + token_type: "Access Token", + clientId: client_id, + }; + + const access_token = jwt.sign(payload, secretKey, { + expiresIn: 1800, // 30 min + }); + + res.status(200).json({ access_token }); +} diff --git a/apps/web/pages/api/auth/oauth/token.ts b/apps/web/pages/api/auth/oauth/token.ts new file mode 100644 index 0000000000..6ad4d1e071 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/token.ts @@ -0,0 +1,104 @@ +import jwt from "jsonwebtoken"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +export type OAuthTokenPayload = { + userId?: number | null; + teamId?: number | null; + token_type: string; + scope: string[]; + clientId: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(405).json({ message: "Invalid method" }); + return; + } + + const { code, client_id, client_secret, grant_type, redirect_uri } = req.body; + + if (grant_type !== "authorization_code") { + res.status(400).json({ message: "grant_type invalid" }); + return; + } + + const [hashedSecret] = generateSecret(client_secret); + + const client = await prisma.oAuthClient.findFirst({ + where: { + clientId: client_id, + clientSecret: hashedSecret, + }, + select: { + redirectUri: true, + }, + }); + + if (!client || client.redirectUri !== redirect_uri) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const accessCode = await prisma.accessCode.findFirst({ + where: { + code: code, + clientId: client_id, + expiresAt: { + gt: new Date(), + }, + }, + }); + + //delete all expired accessCodes + the one that is used here + await prisma.accessCode.deleteMany({ + where: { + OR: [ + { + expiresAt: { + lt: new Date(), + }, + }, + { + code: code, + clientId: client_id, + }, + ], + }, + }); + + if (!accessCode) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + + const payloadAuthToken: OAuthTokenPayload = { + userId: accessCode.userId, + teamId: accessCode.teamId, + scope: accessCode.scopes, + token_type: "Access Token", + clientId: client_id, + }; + + const payloadRefreshToken: OAuthTokenPayload = { + userId: accessCode.userId, + teamId: accessCode.teamId, + scope: accessCode.scopes, + token_type: "Refresh Token", + clientId: client_id, + }; + + const access_token = jwt.sign(payloadAuthToken, secretKey, { + expiresIn: 1800, // 30 min + }); + + const refresh_token = jwt.sign(payloadRefreshToken, secretKey, { + expiresIn: 30 * 24 * 60 * 60, // 30 days + }); + + res.status(200).json({ access_token, refresh_token }); +} diff --git a/apps/web/pages/api/trpc/oAuth/[trpc].ts b/apps/web/pages/api/trpc/oAuth/[trpc].ts new file mode 100644 index 0000000000..d4569d82f3 --- /dev/null +++ b/apps/web/pages/api/trpc/oAuth/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { oAuthRouter } from "@calcom/trpc/server/routers/viewer/oAuth/_router"; + +export default createNextApiHandler(oAuthRouter); diff --git a/apps/web/pages/auth/oauth2/authorize.tsx b/apps/web/pages/auth/oauth2/authorize.tsx new file mode 100644 index 0000000000..6b2c276aac --- /dev/null +++ b/apps/web/pages/auth/oauth2/authorize.tsx @@ -0,0 +1,179 @@ +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar, Button, Select } from "@calcom/ui"; +import { Plus, Info } from "@calcom/ui/components/icon"; + +import PageWrapper from "@components/PageWrapper"; + +export default function Authorize() { + const { t } = useLocale(); + const { status } = useSession(); + + const router = useRouter(); + const searchParams = useSearchParams(); + + const client_id = searchParams?.get("client_id") as string; + const state = searchParams?.get("state") as string; + const scope = searchParams?.get("scope") as string; + + const queryString = searchParams.toString(); + + const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>(); + const scopes = scope ? scope.toString().split(",") : []; + + const { data: client, isLoading: isLoadingGetClient } = trpc.viewer.oAuth.getClient.useQuery( + { + clientId: client_id as string, + }, + { + enabled: status !== "loading", + } + ); + + const { data, isLoading: isLoadingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery(); + + const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({ + onSuccess: (data) => { + window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; + }, + }); + + const mappedProfiles = data + ? data + .filter((profile) => !profile.readOnly) + .map((profile) => ({ + label: profile.name || profile.slug || "", + value: profile.slug || "", + })) + : []; + + useEffect(() => { + if (mappedProfiles.length > 0) { + setSelectedAccount(mappedProfiles[0]); + } + }, [isLoadingProfiles]); + + useEffect(() => { + if (status === "unauthenticated") { + const urlSearchParams = new URLSearchParams({ + callbackUrl: `auth/oauth2/authorize?${queryString}`, + }); + router.replace(`/auth/login?${urlSearchParams.toString()}`); + } + }, [status]); + + const isLoading = isLoadingGetClient || isLoadingProfiles || status !== "authenticated"; + + if (isLoading) { + return <>; + } + + if (!client) { + return
{t("unauthorized")}
; + } + + return ( +
+
+
+ } + className="items-center" + imageSrc={client.logo} + size="lg" + /> +
+
+
+ Logo +
+
+
+
+

+ {t("access_cal_account", { clientName: client.name, appName: APP_NAME })} +

+
{t("select_account_team")}
+