feat: OAuth provider for Zapier (#11465)

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sajanlamsal <saznlamsal@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Nitin Panghal <nitin.panghal@unthinkable.co>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
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 <annleefores@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
This commit is contained in:
Carina Wollendorfer 2023-09-28 21:41:28 +02:00 committed by GitHub
parent b4f44e9a60
commit 68bd877c5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1896 additions and 196 deletions

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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);

View File

@ -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 <div>{t("unauthorized")}</div>;
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="mt-2 max-w-xl rounded-md bg-white px-9 pb-3 pt-2">
<div className="flex items-center justify-center">
<Avatar
alt=""
fallback={<Plus className="text-subtle h-6 w-6" />}
className="items-center"
imageSrc={client.logo}
size="lg"
/>
<div className="relative -ml-6 h-24 w-24">
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-[70px] w-[70px] items-center justify-center rounded-full bg-white">
<img src="/cal-com-icon.svg" alt="Logo" className="h-16 w-16 rounded-full" />
</div>
</div>
</div>
</div>
<h1 className="px-5 pb-5 pt-3 text-center text-2xl font-bold tracking-tight">
{t("access_cal_account", { clientName: client.name, appName: APP_NAME })}
</h1>
<div className="mb-1 text-sm font-medium">{t("select_account_team")}</div>
<Select
isSearchable={true}
id="account-select"
onChange={(value) => {
setSelectedAccount(value);
}}
className="w-52"
defaultValue={selectedAccount || mappedProfiles[0]}
options={mappedProfiles}
/>
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
<ul className="space-y-4 text-sm">
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span>{" "}
{t("associate_with_cal_account", { clientName: client.name })}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("see_personal_info")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("see_primary_email_address")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("connect_installed_apps")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_event_type")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_availability")}
</li>
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span> {t("access_bookings")}
</li>
</ul>
<div className="bg-subtle mb-8 mt-8 flex rounded-md p-3">
<div>
<Info className="mr-1 mt-0.5 h-4 w-4" />
</div>
<div className="ml-1 ">
<div className="mb-1 text-sm font-medium">
{t("allow_client_to_do", { clientName: client.name })}
</div>
<div className="text-sm">{t("oauth_access_information", { appName: APP_NAME })}</div>{" "}
</div>
</div>
<div className="border-subtle border- -mx-9 mb-4 border-b" />
<div className="flex justify-end">
<Button
className="mr-2"
color="minimal"
onClick={() => {
window.location.href = `${client.redirectUri}`;
}}>
{t("go_back")}
</Button>
<Button
onClick={() => {
generateAuthCodeMutation.mutate({
clientId: client_id as string,
scopes,
teamSlug: selectedAccount?.value.startsWith("team/")
? selectedAccount?.value.substring(5)
: undefined, // team account starts with /team/<slug>
});
}}
data-testid="allow-button">
{t("allow")}
</Button>
</div>
</div>
</div>
);
}
Authorize.PageWrapper = PageWrapper;

View File

@ -0,0 +1,11 @@
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "@components/auth/layouts/AdminLayout";
import OAuthView from "./oAuthView";
const OAuthPage = () => <OAuthView />;
OAuthPage.getLayout = getLayout;
OAuthPage.PageWrapper = PageWrapper;
export default OAuthPage;

View File

@ -0,0 +1,151 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Meta, Form, Button, TextField, showToast, Tooltip, ImageUploader, Avatar } from "@calcom/ui";
import { Clipboard } from "@calcom/ui/components/icon";
import { Plus } from "@calcom/ui/components/icon";
type FormValues = {
name: string;
redirectUri: string;
logo: string;
};
export default function OAuthView() {
const oAuthForm = useForm<FormValues>();
const [clientSecret, setClientSecret] = useState("");
const [clientId, setClientId] = useState("");
const [logo, setLogo] = useState("");
const { t } = useLocale();
const mutation = trpc.viewer.oAuth.addClient.useMutation({
onSuccess: async (data) => {
setClientSecret(data.clientSecret);
setClientId(data.clientId);
showToast(`Successfully added ${data.name} as new client`, "success");
},
onError: (error) => {
showToast(`Adding clientfailed: ${error.message}`, "error");
},
});
return (
<div>
<Meta title="OAuth" description="Add new OAuth Clients" />
{!clientId ? (
<Form
form={oAuthForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name,
redirectUri: values.redirectUri,
logo: values.logo,
});
}}>
<div className="">
<TextField
{...oAuthForm.register("name")}
label="Client name"
type="text"
id="name"
placeholder=""
className="mb-3"
required
/>
<TextField
{...oAuthForm.register("redirectUri")}
label="Redirect URI"
type="text"
id="redirectUri"
placeholder=""
required
/>
<div className="mb-5 mt-5 flex items-center">
<Avatar
alt=""
fallback={<Plus className="text-subtle h-6 w-6" />}
className="mr-5 items-center"
imageSrc={logo}
size="lg"
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Upload Logo"
handleAvatarChange={(newLogo: string) => {
setLogo(newLogo);
oAuthForm.setValue("logo", newLogo);
}}
imageSrc={logo}
/>
</div>
</div>
<Button type="submit" className="mt-3">
{t("add_client")}
</Button>
</Form>
) : (
<div>
<div className="text-emphasis mb-5 text-xl font-semibold">{oAuthForm.getValues("name")}</div>
<div className="mb-2 font-medium">Client Id</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientId}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientId);
showToast("Client ID copied!", "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon={Clipboard}>
{t("copy")}
</Button>
</Tooltip>
</div>
{clientSecret ? (
<>
<div className="mb-2 mt-4 font-medium">Client Secret</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientSecret}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientSecret);
setClientSecret("");
showToast("Client secret copied!", "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon={Clipboard}>
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="text-subtle text-sm">{t("copy_client_secret_info")}</div>
</>
) : (
<></>
)}
<Button
onClick={() => {
setClientId("");
setLogo("");
oAuthForm.reset();
}}
className="mt-5">
{t("add_new_client")}
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,227 @@
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import { test } from "./lib/fixtures";
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
let client: {
clientId: string;
redirectUri: string;
orginalSecret: string;
name: string;
clientSecret: string;
logo: string | null;
};
test.describe("OAuth Provider", () => {
test.beforeAll(async () => {
client = await createTestCLient();
});
test("should create valid access toke & refresh token for user", async ({ page, users }) => {
const user = await users.create({ username: "test user", name: "test user" });
await user.apiLogin();
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.waitForLoadState("networkidle");
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: JSON.stringify({
code,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "authorization_code",
redirect_uri: client.redirectUri,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const tokenData = await tokenResponse.json();
// test if token is valid
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + tokenData.access_token,
},
});
const meData = await meResponse.json();
// check if user access token is valid
expect(meData.username.startsWith("test user")).toBe(true);
// request new token with refresh token
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: JSON.stringify({
refresh_token: tokenData.refresh_token,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "refresh_token",
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + tokenData.access_token,
},
});
expect(meData.username.startsWith("test user")).toBe(true);
});
test("should create valid access toke & refresh token for team", async ({ page, users }) => {
const user = await users.create({ username: "test user", name: "test user" }, { hasTeam: true });
await user.apiLogin();
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.waitForLoadState("networkidle");
await page.locator("#account-select").click();
await page.locator("#react-select-2-option-1").click();
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: JSON.stringify({
code,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "authorization_code",
redirect_uri: client.redirectUri,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const tokenData = await tokenResponse.json();
// test if token is valid
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + tokenData.access_token,
},
});
const meData = await meResponse.json();
// check if team access token is valid
expect(meData.username.endsWith("Team Team")).toBe(true);
// request new token with refresh token
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: JSON.stringify({
refresh_token: tokenData.refresh_token,
client_id: client.clientId,
client_secret: client.orginalSecret,
grant_type: "refresh_token",
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + tokenData.access_token,
},
});
expect(meData.username.endsWith("Team Team")).toBe(true);
});
test("redirect not logged-in users to login page and after forward to authorization page", async ({
page,
users,
}) => {
const user = await users.create({ username: "test-user", name: "test user" });
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
// check if user is redirected to login page
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await page.locator("#email").fill(user.email);
await page.locator("#password").fill(user.username || "");
await page.locator('[type="submit"]').click();
await page.waitForSelector("#account-select");
await expect(page.getByText("test user")).toBeVisible();
});
});
const createTestCLient = async () => {
const [hashedSecret, secret] = generateSecret();
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
name: "Test Client",
clientId,
clientSecret: hashedSecret,
redirectUri: "https://example.com",
},
});
return { ...client, orginalSecret: secret };
};

View File

@ -2054,15 +2054,33 @@
"team_no_event_types": "This team has no event types",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"include_calendar_event": "Include calendar event",
"oAuth": "OAuth",
"recently_added":"Recently added",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
"unauthorized":"Unauthorized",
"access_cal_account": "{{clientName}} would like access to your {{appName}} account",
"select_account_team": "Select account or team",
"allow_client_to": "This will allow {{clientName}} to",
"associate_with_cal_account":"Associate you with your personal info from {{clientName}}",
"see_personal_info":"See your personal info, including any personal info you've made publicly available",
"see_primary_email_address":"See your primary email address",
"connect_installed_apps":"Connect to your installed apps",
"access_event_type": "Read, edit, delete your event-types",
"access_availability": "Read, edit, delete your availability",
"access_bookings": "Read, edit, delete your bookings",
"allow_client_to_do": "Allow {{clientName}} to do this?",
"oauth_access_information": "By clicking allow, you allow this app to use your information in accordance with their terms of service and privacy policy. You can remove access in the {{appName}} App Store.",
"allow": "Allow",
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"add_client": "Add client",
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",
"this_app_is_not_setup_already": "This app has not been setup yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -78,6 +78,7 @@
"@playwright/test": "^1.31.2",
"@snaplet/copycat": "^0.3.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/jsonwebtoken": "^9.0.3",
"c8": "^7.13.0",
"dotenv-checker": "^1.1.5",
"husky": "^8.0.0",

View File

@ -1,26 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
import { addSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.query.apiKey as string;
if (!apiKey) {
return res.status(401).json({ message: "No API key provided" });
}
const validKey = await findValidApiKey(apiKey, "zapier");
if (!validKey) {
return res.status(401).json({ message: "API key not valid" });
}
const { subscriberUrl, triggerEvent } = req.body;
const { account, appApiKey } = await validateAccountOrApiKey(req, ["READ_BOOKING", "READ_PROFILE"]);
const createAppSubscription = await addSubscription({
appApiKey: validKey,
appApiKey,
account,
triggerEvent: triggerEvent,
subscriberUrl: subscriberUrl,
appId: "zapier",

View File

@ -1,30 +1,24 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
const querySchema = z.object({
apiKey: z.string(),
id: z.string(),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { apiKey, id } = querySchema.parse(req.query);
const { id } = querySchema.parse(req.query);
if (!apiKey) {
return res.status(401).json({ message: "No API key provided" });
}
const validKey = await findValidApiKey(apiKey, "zapier");
if (!validKey) {
return res.status(401).json({ message: "API key not valid" });
}
const { account, appApiKey } = await validateAccountOrApiKey(req, ["READ_BOOKING", "READ_PROFILE"]);
const deleteEventSubscription = await deleteSubscription({
appApiKey: validKey,
appApiKey,
account,
webhookId: id,
appId: "zapier",
});

View File

@ -1,29 +1,32 @@
import type { NextApiRequest, NextApiResponse } from "next";
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
import { listBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.query.apiKey as string;
if (!apiKey) {
return res.status(401).json({ message: "No API key provided" });
}
const validKey = await findValidApiKey(apiKey, "zapier");
if (!validKey) {
return res.status(401).json({ message: "API key not valid" });
}
const bookings = await listBookings(validKey);
const { account: authorizedAccount, appApiKey: validKey } = await validateAccountOrApiKey(req, [
"READ_BOOKING",
]);
const bookings = await listBookings(validKey, authorizedAccount);
if (!bookings) {
return res.status(500).json({ message: "Unable to get bookings." });
}
if (bookings.length === 0) {
const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId;
const userInfo = validKey
? validKey.userId
: authorizedAccount && !authorizedAccount.isTeam
? authorizedAccount.name
: null;
const teamInfo = validKey
? validKey.teamId
: authorizedAccount && authorizedAccount.isTeam
? authorizedAccount.name
: null;
const requested = teamInfo ? "team: " + teamInfo : "user: " + userInfo;
return res.status(404).json({
message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``,
});

View File

@ -0,0 +1,19 @@
import type { NextApiRequest } from "next";
import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization";
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
import { HttpError } from "@calcom/lib/http-error";
export async function validateAccountOrApiKey(req: NextApiRequest, requiredScopes: string[] = []) {
const apiKey = req.query.apiKey as string;
if (!apiKey) {
const authorizedAccount = await isAuthorized(req, requiredScopes);
if (!authorizedAccount) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
return { account: authorizedAccount, appApiKey: undefined };
}
const validKey = await findValidApiKey(apiKey, "zapier");
if (!validKey) throw new HttpError({ statusCode: 401, message: "API key not valid" });
return { account: null, appApiKey: validKey };
}

View File

@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
// eslint-disable-next-line no-restricted-imports
import { test } from "@calcom/web/playwright/lib/fixtures";
import "../../src/types";

View File

@ -12,14 +12,14 @@ import { getCalApi } from "@calcom/embed-react";
const api = getCalApi();
test("Check that the API is available", async () => {
expect(api).toBeDefined()
expect(api).toBeDefined();
const awaitedApi = await api;
awaitedApi('floatingButton', {
calLink: 'free',
awaitedApi("floatingButton", {
calLink: "free",
config: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error We are intentionaly testing invalid value
layout: 'wrongview'
}
})
layout: "wrongview",
},
});
});

View File

@ -0,0 +1,55 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest } from "next";
import prisma from "@calcom/prisma";
import type { OAuthTokenPayload } from "@calcom/web/pages/api/auth/oauth/token";
export default async function isAuthorized(req: NextApiRequest, requiredScopes: string[] = []) {
const token = req.headers.authorization?.split(" ")[1] || "";
let decodedToken: OAuthTokenPayload;
try {
decodedToken = jwt.verify(token, process.env.CALENDSO_ENCRYPTION_KEY || "") as OAuthTokenPayload;
} catch {
return null;
}
if (!decodedToken) return null;
const hasAllRequiredScopes = requiredScopes.every((scope) => decodedToken.scope.includes(scope));
if (!hasAllRequiredScopes || decodedToken.token_type !== "Access Token") {
return null;
}
if (decodedToken.userId) {
const user = await prisma.user.findFirst({
where: {
id: decodedToken.userId,
},
select: {
id: true,
username: true,
},
});
if (!user) return null;
return { id: user.id, name: user.username, isTeam: false };
}
if (decodedToken.teamId) {
const team = await prisma.team.findFirst({
where: {
id: decodedToken.teamId,
},
select: {
id: true,
name: true,
},
});
if (!team) return null;
return { ...team, isTeam: true };
}
return null;
}

View File

@ -117,6 +117,7 @@ const tabs: VerticalTabItemProps[] = [
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "/settings/admin/users" },
{ name: "organizations", href: "/settings/admin/organizations" },
{ name: "oAuth", href: "/settings/admin/oAuth" },
],
},
];

View File

@ -16,18 +16,27 @@ export async function addSubscription({
triggerEvent,
subscriberUrl,
appId,
account,
}: {
appApiKey: ApiKey;
appApiKey?: ApiKey;
triggerEvent: WebhookTriggerEvents;
subscriberUrl: string;
appId: string;
account?: {
id: number;
name: string | null;
isTeam: boolean;
} | null;
}) {
try {
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
const createSubscription = await prisma.webhook.create({
data: {
id: v4(),
userId: appApiKey.userId,
teamId: appApiKey.teamId,
userId,
teamId,
eventTriggers: [triggerEvent],
subscriberUrl,
active: true,
@ -38,8 +47,11 @@ export async function addSubscription({
if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) {
//schedule job for already existing bookings
const where: Prisma.BookingWhereInput = {};
if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId };
else where.userId = appApiKey.userId;
if (teamId) {
where.eventType = { teamId };
} else {
where.userId = userId;
}
const bookings = await prisma.booking.findMany({
where: {
...where,
@ -60,7 +72,10 @@ export async function addSubscription({
return createSubscription;
} catch (error) {
log.error(`Error creating subscription for user ${appApiKey.userId} and appId ${appApiKey.appId}.`);
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
log.error(`Error creating subscription for ${teamId ? `team ${teamId}` : `user ${userId}`}.`);
}
}
@ -68,10 +83,16 @@ export async function deleteSubscription({
appApiKey,
webhookId,
appId,
account,
}: {
appApiKey: ApiKey;
appApiKey?: ApiKey;
webhookId: string;
appId: string;
account?: {
id: number;
name: string | null;
isTeam: boolean;
} | null;
}) {
try {
const webhook = await prisma.webhook.findFirst({
@ -82,8 +103,21 @@ export async function deleteSubscription({
if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) {
const where: Prisma.BookingWhereInput = {};
if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId };
else where.userId = appApiKey.userId;
if (appApiKey) {
if (appApiKey.teamId) {
where.eventType = { teamId: appApiKey.teamId };
} else {
where.userId = appApiKey.userId;
}
} else if (account) {
if (account.isTeam) {
where.eventType = { teamId: account.id };
} else {
where.userId = account.id;
}
}
const bookingsWithScheduledJobs = await prisma.booking.findMany({
where: {
...where,
@ -117,22 +151,48 @@ export async function deleteSubscription({
}
return deleteWebhook;
} catch (err) {
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
log.error(
`Error deleting subscription for user ${appApiKey.userId}, webhookId ${webhookId}, appId ${appId}`
`Error deleting subscription for user ${
teamId ? `team ${teamId}` : `userId ${userId}`
}, webhookId ${webhookId}`
);
}
}
export async function listBookings(appApiKey: ApiKey) {
export async function listBookings(
appApiKey?: ApiKey,
account?: {
id: number;
name: string | null;
isTeam: boolean;
} | null
) {
try {
const where: Prisma.BookingWhereInput = {};
if (appApiKey.teamId) {
where.eventType = {
OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }],
};
} else {
where.userId = appApiKey.userId;
if (appApiKey) {
if (appApiKey.teamId) {
where.eventType = {
OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }],
};
} else {
where.userId = appApiKey.userId;
}
} else if (account) {
if (!account.isTeam) {
where.userId = account.id;
where.eventType = {
teamId: null,
};
} else {
where.eventType = {
teamId: account.id,
};
}
}
const bookings = await prisma.booking.findMany({
take: 3,
where: where,
@ -197,7 +257,10 @@ export async function listBookings(appApiKey: ApiKey) {
return updatedBookings;
} catch (err) {
log.error(`Error retrieving list of bookings for user ${appApiKey.userId} and appId ${appApiKey.appId}.`);
const userId = appApiKey ? appApiKey.userId : account && !account.isTeam ? account.id : null;
const teamId = appApiKey ? appApiKey.teamId : account && account.isTeam ? account.id : null;
log.error(`Error retrieving list of bookings for ${teamId ? `team ${teamId}` : `user ${userId}`}.`);
}
}

View File

@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "AccessScope" AS ENUM ('READ_BOOKING', 'READ_PROFILE');
-- CreateTable
CREATE TABLE "OAuthClient" (
"clientId" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"clientSecret" TEXT NOT NULL,
"name" TEXT NOT NULL,
"logo" TEXT,
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("clientId")
);
-- CreateTable
CREATE TABLE "AccessCode" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"clientId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"scopes" "AccessScope"[],
"userId" INTEGER,
"teamId" INTEGER,
CONSTRAINT "AccessCode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");
-- AddForeignKey
ALTER TABLE "AccessCode" ADD CONSTRAINT "AccessCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("clientId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccessCode" ADD CONSTRAINT "AccessCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccessCode" ADD CONSTRAINT "AccessCode_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -244,6 +244,7 @@ model User {
hosts Host[]
organizationId Int?
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
accessCodes AccessCode[]
// Linking account code for orgs v2
//linkedByUserId Int?
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
@ -293,6 +294,7 @@ model Team {
routingForms App_RoutingForms_Form[]
apiKeys ApiKey[]
credentials Credential[]
accessCodes AccessCode[]
@@unique([slug, parentId])
}
@ -908,6 +910,33 @@ model SelectedSlots {
@@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique")
}
model OAuthClient {
clientId String @id @unique
redirectUri String
clientSecret String
name String
logo String?
accessCodes AccessCode[]
}
model AccessCode {
id Int @id @default(autoincrement())
code String
clientId String?
client OAuthClient? @relation(fields: [clientId], references: [clientId], onDelete: Cascade)
expiresAt DateTime
scopes AccessScope[]
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum AccessScope {
READ_BOOKING
READ_PROFILE
}
view BookingTimeStatus {
id Int @unique
uid String?

View File

@ -41,6 +41,7 @@ const ENDPOINTS = [
"workflows",
"appsRouter",
"googleWorkspace",
"oAuth",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];

View File

@ -16,6 +16,7 @@ import { bookingsRouter } from "./bookings/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { oAuthRouter } from "./oAuth/_router";
import { viewerOrganizationsRouter } from "./organizations/_router";
import { paymentsRouter } from "./payments/_router";
import { slotsRouter } from "./slots/_router";
@ -50,6 +51,7 @@ export const viewerRouter = mergeRouters(
features: featureFlagRouter,
appsRouter,
users: userAdminRouter,
oAuth: oAuthRouter,
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
})

View File

@ -0,0 +1,68 @@
import authedProcedure, { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAddClientInputSchema } from "./addClient.schema";
import { ZGenerateAuthCodeInputSchema } from "./generateAuthCode.schema";
import { ZGetClientInputSchema } from "./getClient.schema";
type OAuthRouterHandlerCache = {
getClient?: typeof import("./getClient.handler").getClientHandler;
addClient?: typeof import("./addClient.handler").addClientHandler;
generateAuthCode?: typeof import("./generateAuthCode.handler").generateAuthCodeHandler;
};
const UNSTABLE_HANDLER_CACHE: OAuthRouterHandlerCache = {};
export const oAuthRouter = router({
getClient: authedProcedure.input(ZGetClientInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getClient) {
UNSTABLE_HANDLER_CACHE.getClient = await import("./getClient.handler").then(
(mod) => mod.getClientHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getClient) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getClient({
input,
});
}),
addClient: authedAdminProcedure.input(ZAddClientInputSchema).mutation(async ({ input }) => {
if (!UNSTABLE_HANDLER_CACHE.addClient) {
UNSTABLE_HANDLER_CACHE.addClient = await import("./addClient.handler").then(
(mod) => mod.addClientHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.addClient) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.addClient({
input,
});
}),
generateAuthCode: authedProcedure.input(ZGenerateAuthCodeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.generateAuthCode) {
UNSTABLE_HANDLER_CACHE.generateAuthCode = await import("./generateAuthCode.handler").then(
(mod) => mod.generateAuthCodeHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.generateAuthCode) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.generateAuthCode({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,38 @@
import { randomBytes, createHash } from "crypto";
import { prisma } from "@calcom/prisma";
import type { TAddClientInputSchema } from "./addClient.schema";
type AddClientOptions = {
input: TAddClientInputSchema;
};
export const addClientHandler = async ({ input }: AddClientOptions) => {
const { name, redirectUri, logo } = input;
const [hashedSecret, secret] = generateSecret();
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
name,
redirectUri,
clientId,
clientSecret: hashedSecret,
logo,
},
});
const clientWithSecret = {
...client,
clientSecret: secret,
};
return clientWithSecret;
};
const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
// Generate a random secret
export const generateSecret = (secret = randomBytes(32).toString("hex")) => [hashSecretKey(secret), secret];

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const ZAddClientInputSchema = z.object({
name: z.string(),
redirectUri: z.string(),
logo: z.string(),
});
export type TAddClientInputSchema = z.infer<typeof ZAddClientInputSchema>;

View File

@ -0,0 +1,78 @@
import { randomBytes } from "crypto";
import dayjs from "@calcom/dayjs";
import { prisma } from "@calcom/prisma";
import type { AccessScope } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TGenerateAuthCodeInputSchema } from "./generateAuthCode.schema";
type AddClientOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGenerateAuthCodeInputSchema;
};
export const generateAuthCodeHandler = async ({ ctx, input }: AddClientOptions) => {
const { clientId, scopes, teamSlug } = input;
const client = await prisma.oAuthClient.findFirst({
where: {
clientId,
},
select: {
clientId: true,
redirectUri: true,
name: true,
},
});
if (!client) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Client ID not valid" });
}
const authorizationCode = generateAuthorizationCode();
const team = teamSlug
? await prisma.team.findFirst({
where: {
slug: teamSlug,
members: {
some: {
userId: ctx.user.id,
role: {
in: ["OWNER", "ADMIN"],
},
},
},
},
})
: undefined;
if (teamSlug && !team) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await prisma.accessCode.create({
data: {
code: authorizationCode,
clientId,
userId: !teamSlug ? ctx.user.id : undefined,
teamId: team ? team.id : undefined,
expiresAt: dayjs().add(10, "minutes").toDate(),
scopes: scopes as [AccessScope],
},
});
return { client, authorizationCode };
};
function generateAuthorizationCode() {
const randomBytesValue = randomBytes(40);
const authorizationCode = randomBytesValue
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
return authorizationCode;
}

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const ZGenerateAuthCodeInputSchema = z.object({
clientId: z.string(),
scopes: z.array(z.string()),
teamSlug: z.string().optional(),
});
export type TGenerateAuthCodeInputSchema = z.infer<typeof ZGenerateAuthCodeInputSchema>;

View File

@ -0,0 +1,24 @@
import { prisma } from "@calcom/prisma";
import type { TGetClientInputSchema } from "./getClient.schema";
type GetClientOptions = {
input: TGetClientInputSchema;
};
export const getClientHandler = async ({ input }: GetClientOptions) => {
const { clientId } = input;
const client = await prisma.oAuthClient.findFirst({
where: {
clientId,
},
select: {
clientId: true,
redirectUri: true,
name: true,
logo: true,
},
});
return client;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetClientInputSchema = z.object({
clientId: z.string(),
});
export type TGetClientInputSchema = z.infer<typeof ZGetClientInputSchema>;

754
yarn.lock

File diff suppressed because it is too large Load Diff