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>pull/11598/head^2
parent
b4f44e9a60
commit
68bd877c5b
|
@ -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 });
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
|
@ -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);
|
|
@ -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">✓</span>{" "}
|
||||||
|
{t("associate_with_cal_account", { clientName: client.name })}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("see_personal_info")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("see_primary_email_address")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("connect_installed_apps")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("access_event_type")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</span> {t("access_availability")}
|
||||||
|
</li>
|
||||||
|
<li className="relative pl-5">
|
||||||
|
<span className="absolute left-0">✓</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;
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 };
|
||||||
|
};
|
|
@ -2054,15 +2054,33 @@
|
||||||
"team_no_event_types": "This team has no event types",
|
"team_no_event_types": "This team has no event types",
|
||||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||||
"include_calendar_event": "Include calendar event",
|
"include_calendar_event": "Include calendar event",
|
||||||
|
"oAuth": "OAuth",
|
||||||
"recently_added":"Recently added",
|
"recently_added":"Recently added",
|
||||||
"no_members_found": "No members found",
|
"no_members_found": "No members found",
|
||||||
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
|
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
|
||||||
"availability_schedules":"Availability Schedules",
|
"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_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.",
|
"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}}",
|
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||||
"resend_invitation": "Resend invitation",
|
"resend_invitation": "Resend invitation",
|
||||||
"invitation_resent": "The invitation was resent.",
|
"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",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,7 @@
|
||||||
"@playwright/test": "^1.31.2",
|
"@playwright/test": "^1.31.2",
|
||||||
"@snaplet/copycat": "^0.3.0",
|
"@snaplet/copycat": "^0.3.0",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@types/jsonwebtoken": "^9.0.3",
|
||||||
"c8": "^7.13.0",
|
"c8": "^7.13.0",
|
||||||
"dotenv-checker": "^1.1.5",
|
"dotenv-checker": "^1.1.5",
|
||||||
"husky": "^8.0.0",
|
"husky": "^8.0.0",
|
||||||
|
|
|
@ -1,26 +1,16 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 { addSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
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 { subscriberUrl, triggerEvent } = req.body;
|
||||||
|
const { account, appApiKey } = await validateAccountOrApiKey(req, ["READ_BOOKING", "READ_PROFILE"]);
|
||||||
const createAppSubscription = await addSubscription({
|
const createAppSubscription = await addSubscription({
|
||||||
appApiKey: validKey,
|
appApiKey,
|
||||||
|
account,
|
||||||
triggerEvent: triggerEvent,
|
triggerEvent: triggerEvent,
|
||||||
subscriberUrl: subscriberUrl,
|
subscriberUrl: subscriberUrl,
|
||||||
appId: "zapier",
|
appId: "zapier",
|
||||||
|
|
|
@ -1,30 +1,24 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey";
|
|
||||||
import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { apiKey, id } = querySchema.parse(req.query);
|
const { id } = querySchema.parse(req.query);
|
||||||
|
|
||||||
if (!apiKey) {
|
const { account, appApiKey } = await validateAccountOrApiKey(req, ["READ_BOOKING", "READ_PROFILE"]);
|
||||||
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 deleteEventSubscription = await deleteSubscription({
|
const deleteEventSubscription = await deleteSubscription({
|
||||||
appApiKey: validKey,
|
appApiKey,
|
||||||
|
account,
|
||||||
webhookId: id,
|
webhookId: id,
|
||||||
appId: "zapier",
|
appId: "zapier",
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,29 +1,32 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 { listBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { validateAccountOrApiKey } from "../../lib/validateAccountOrApiKey";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const apiKey = req.query.apiKey as string;
|
const { account: authorizedAccount, appApiKey: validKey } = await validateAccountOrApiKey(req, [
|
||||||
|
"READ_BOOKING",
|
||||||
if (!apiKey) {
|
]);
|
||||||
return res.status(401).json({ message: "No API key provided" });
|
const bookings = await listBookings(validKey, authorizedAccount);
|
||||||
}
|
|
||||||
|
|
||||||
const validKey = await findValidApiKey(apiKey, "zapier");
|
|
||||||
|
|
||||||
if (!validKey) {
|
|
||||||
return res.status(401).json({ message: "API key not valid" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookings = await listBookings(validKey);
|
|
||||||
|
|
||||||
if (!bookings) {
|
if (!bookings) {
|
||||||
return res.status(500).json({ message: "Unable to get bookings." });
|
return res.status(500).json({ message: "Unable to get bookings." });
|
||||||
}
|
}
|
||||||
if (bookings.length === 0) {
|
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({
|
return res.status(404).json({
|
||||||
message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``,
|
message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 };
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||||
|
|
||||||
import "../../src/types";
|
import "../../src/types";
|
||||||
|
|
|
@ -12,14 +12,14 @@ import { getCalApi } from "@calcom/embed-react";
|
||||||
const api = getCalApi();
|
const api = getCalApi();
|
||||||
|
|
||||||
test("Check that the API is available", async () => {
|
test("Check that the API is available", async () => {
|
||||||
expect(api).toBeDefined()
|
expect(api).toBeDefined();
|
||||||
const awaitedApi = await api;
|
const awaitedApi = await api;
|
||||||
awaitedApi('floatingButton', {
|
awaitedApi("floatingButton", {
|
||||||
calLink: 'free',
|
calLink: "free",
|
||||||
config: {
|
config: {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error We are intentionaly testing invalid value
|
// @ts-expect-error We are intentionaly testing invalid value
|
||||||
layout: 'wrongview'
|
layout: "wrongview",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -117,6 +117,7 @@ const tabs: VerticalTabItemProps[] = [
|
||||||
{ name: "apps", href: "/settings/admin/apps/calendar" },
|
{ name: "apps", href: "/settings/admin/apps/calendar" },
|
||||||
{ name: "users", href: "/settings/admin/users" },
|
{ name: "users", href: "/settings/admin/users" },
|
||||||
{ name: "organizations", href: "/settings/admin/organizations" },
|
{ name: "organizations", href: "/settings/admin/organizations" },
|
||||||
|
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -16,18 +16,27 @@ export async function addSubscription({
|
||||||
triggerEvent,
|
triggerEvent,
|
||||||
subscriberUrl,
|
subscriberUrl,
|
||||||
appId,
|
appId,
|
||||||
|
account,
|
||||||
}: {
|
}: {
|
||||||
appApiKey: ApiKey;
|
appApiKey?: ApiKey;
|
||||||
triggerEvent: WebhookTriggerEvents;
|
triggerEvent: WebhookTriggerEvents;
|
||||||
subscriberUrl: string;
|
subscriberUrl: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
isTeam: boolean;
|
||||||
|
} | null;
|
||||||
}) {
|
}) {
|
||||||
try {
|
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({
|
const createSubscription = await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
userId: appApiKey.userId,
|
userId,
|
||||||
teamId: appApiKey.teamId,
|
teamId,
|
||||||
eventTriggers: [triggerEvent],
|
eventTriggers: [triggerEvent],
|
||||||
subscriberUrl,
|
subscriberUrl,
|
||||||
active: true,
|
active: true,
|
||||||
|
@ -38,8 +47,11 @@ export async function addSubscription({
|
||||||
if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) {
|
if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) {
|
||||||
//schedule job for already existing bookings
|
//schedule job for already existing bookings
|
||||||
const where: Prisma.BookingWhereInput = {};
|
const where: Prisma.BookingWhereInput = {};
|
||||||
if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId };
|
if (teamId) {
|
||||||
else where.userId = appApiKey.userId;
|
where.eventType = { teamId };
|
||||||
|
} else {
|
||||||
|
where.userId = userId;
|
||||||
|
}
|
||||||
const bookings = await prisma.booking.findMany({
|
const bookings = await prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
...where,
|
...where,
|
||||||
|
@ -60,7 +72,10 @@ export async function addSubscription({
|
||||||
|
|
||||||
return createSubscription;
|
return createSubscription;
|
||||||
} catch (error) {
|
} 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,
|
appApiKey,
|
||||||
webhookId,
|
webhookId,
|
||||||
appId,
|
appId,
|
||||||
|
account,
|
||||||
}: {
|
}: {
|
||||||
appApiKey: ApiKey;
|
appApiKey?: ApiKey;
|
||||||
webhookId: string;
|
webhookId: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
|
account?: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
isTeam: boolean;
|
||||||
|
} | null;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const webhook = await prisma.webhook.findFirst({
|
const webhook = await prisma.webhook.findFirst({
|
||||||
|
@ -82,8 +103,21 @@ export async function deleteSubscription({
|
||||||
|
|
||||||
if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) {
|
if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) {
|
||||||
const where: Prisma.BookingWhereInput = {};
|
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({
|
const bookingsWithScheduledJobs = await prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
...where,
|
...where,
|
||||||
|
@ -117,22 +151,48 @@ export async function deleteSubscription({
|
||||||
}
|
}
|
||||||
return deleteWebhook;
|
return deleteWebhook;
|
||||||
} catch (err) {
|
} 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(
|
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 {
|
try {
|
||||||
const where: Prisma.BookingWhereInput = {};
|
const where: Prisma.BookingWhereInput = {};
|
||||||
if (appApiKey.teamId) {
|
if (appApiKey) {
|
||||||
where.eventType = {
|
if (appApiKey.teamId) {
|
||||||
OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }],
|
where.eventType = {
|
||||||
};
|
OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }],
|
||||||
} else {
|
};
|
||||||
where.userId = appApiKey.userId;
|
} 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({
|
const bookings = await prisma.booking.findMany({
|
||||||
take: 3,
|
take: 3,
|
||||||
where: where,
|
where: where,
|
||||||
|
@ -197,7 +257,10 @@ export async function listBookings(appApiKey: ApiKey) {
|
||||||
|
|
||||||
return updatedBookings;
|
return updatedBookings;
|
||||||
} catch (err) {
|
} 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}`}.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -244,6 +244,7 @@ model User {
|
||||||
hosts Host[]
|
hosts Host[]
|
||||||
organizationId Int?
|
organizationId Int?
|
||||||
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
|
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
|
accessCodes AccessCode[]
|
||||||
// Linking account code for orgs v2
|
// Linking account code for orgs v2
|
||||||
//linkedByUserId Int?
|
//linkedByUserId Int?
|
||||||
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
|
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
|
||||||
|
@ -293,6 +294,7 @@ model Team {
|
||||||
routingForms App_RoutingForms_Form[]
|
routingForms App_RoutingForms_Form[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
|
accessCodes AccessCode[]
|
||||||
|
|
||||||
@@unique([slug, parentId])
|
@@unique([slug, parentId])
|
||||||
}
|
}
|
||||||
|
@ -908,6 +910,33 @@ model SelectedSlots {
|
||||||
@@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique")
|
@@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 {
|
view BookingTimeStatus {
|
||||||
id Int @unique
|
id Int @unique
|
||||||
uid String?
|
uid String?
|
||||||
|
|
|
@ -41,6 +41,7 @@ const ENDPOINTS = [
|
||||||
"workflows",
|
"workflows",
|
||||||
"appsRouter",
|
"appsRouter",
|
||||||
"googleWorkspace",
|
"googleWorkspace",
|
||||||
|
"oAuth",
|
||||||
] as const;
|
] as const;
|
||||||
export type Endpoint = (typeof ENDPOINTS)[number];
|
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { bookingsRouter } from "./bookings/_router";
|
||||||
import { deploymentSetupRouter } from "./deploymentSetup/_router";
|
import { deploymentSetupRouter } from "./deploymentSetup/_router";
|
||||||
import { eventTypesRouter } from "./eventTypes/_router";
|
import { eventTypesRouter } from "./eventTypes/_router";
|
||||||
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||||
|
import { oAuthRouter } from "./oAuth/_router";
|
||||||
import { viewerOrganizationsRouter } from "./organizations/_router";
|
import { viewerOrganizationsRouter } from "./organizations/_router";
|
||||||
import { paymentsRouter } from "./payments/_router";
|
import { paymentsRouter } from "./payments/_router";
|
||||||
import { slotsRouter } from "./slots/_router";
|
import { slotsRouter } from "./slots/_router";
|
||||||
|
@ -50,6 +51,7 @@ export const viewerRouter = mergeRouters(
|
||||||
features: featureFlagRouter,
|
features: featureFlagRouter,
|
||||||
appsRouter,
|
appsRouter,
|
||||||
users: userAdminRouter,
|
users: userAdminRouter,
|
||||||
|
oAuth: oAuthRouter,
|
||||||
googleWorkspace: googleWorkspaceRouter,
|
googleWorkspace: googleWorkspaceRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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];
|
|
@ -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>;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>;
|
|
@ -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;
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ZGetClientInputSchema = z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetClientInputSchema = z.infer<typeof ZGetClientInputSchema>;
|
Loading…
Reference in New Issue