feat: OAuth 2.0 support for atoms (#27158)
* fix: useOAuthClient support OAuth 2.0 * fix: cannot read properties of undefined (reading NEXT_PUBLIC_IS_E2E) * fix: allow OAuth 2.0 token to connect gcal or ms calendar * fix: allow OAuth 2.0 token to save gcal or ms calendar credentials * refactor: dont set oauth id header for OAuth 2.0 * fix: calendar events not showing and emails not sent * feat: CalOAuth2Provider * chore: make OAuth 2.0 work in examples app * chore: refresh OAuth 2.0 tokens * docs: running examples app with oauth 2.0 * fix: remove sensitive console.log statements that leak secrets Remove logging of: - OAuth authorization codes (oauth2-user.ts) - Token-bearing exchange responses (oauth2-user.ts) - /me response data containing PII (oauth2-user.ts) - OAuth2 refresh response with tokens (refresh.ts) - Response payload with access tokens (_app.tsx) Addresses Cubic AI review feedback for issues with confidence >= 9/10 Co-Authored-By: unknown <> * docs: update readme * fix: implemente cubic feedback * fix: seed script import * fix: seed script pkce * fix: correct typos and SQLite capitalization in OAuth2 README (#27176) Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: dont return name in public oauth endpoint * docs: CalOAuthProvider * chore: add NEXT_PUBLIC_IS_E2E constant to test * docs: fix duplicated 'or' in Cal OAuth Provider documentation (#27177) Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> * revert: is e2e constant * fix: typecheck * refactor: example app users select * update readme * chore: update oauth atoms readme * refactor: enable booking managed event types with user.username instead of profile.username * fix: EventTypeSettings when viewing round robin * test: add e2e tests for atoms-oauth2 controller Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com> * fix: correct error message path in atoms-oauth2 e2e test Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
co-authored by
unknown <>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
lauris@cal.com <lauris.skraucis@gmail.com>
lauris@cal.com <lauris.skraucis@gmail.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
Rajiv Sahal
parent
0776bdf5fe
commit
fc602d3b03
@@ -1,13 +1,13 @@
|
||||
// pages/_app.tsx
|
||||
import type { Data } from "@/pages/api/get-managed-users";
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Poppins } from "next/font/google";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import { CalProvider, BookerEmbed, Router } from "@calcom/atoms";
|
||||
import { CalProvider, CalOAuthProvider, BookerEmbed, Router as CalRouter } from "@calcom/atoms";
|
||||
import "@calcom/atoms/globals.min.css";
|
||||
|
||||
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
|
||||
@@ -22,7 +22,6 @@ function generateRandomEmail(name: string) {
|
||||
).join("");
|
||||
|
||||
const randomDomain = domain[Math.floor(Math.random() * domain.length)];
|
||||
|
||||
return `${name}-${randomLocalPart}@${randomDomain}`;
|
||||
}
|
||||
|
||||
@@ -34,33 +33,71 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
const [email, setUserEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<TUser | null>(null);
|
||||
const [options, setOptions] = useState([]);
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const pathname = router.pathname;
|
||||
|
||||
const oAuth2Mode = process.env.NEXT_PUBLIC_OAUTH2_MODE === "true";
|
||||
|
||||
const authorizationCode = useMemo(() => {
|
||||
const code = router.query.code;
|
||||
return typeof code === "string" ? code : null;
|
||||
}, [router.query.code]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/get-managed-users", {
|
||||
method: "get",
|
||||
}).then(async (res) => {
|
||||
fetch("/api/get-users", { method: "get" }).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (data.users.length === 1) {
|
||||
setAccessToken(data.users[0].accessToken);
|
||||
setUserEmail(data.users[0].email);
|
||||
setUsername(data.users[0].username);
|
||||
return;
|
||||
}
|
||||
setOptions(
|
||||
data.users.map((item: Data["users"][0]) => ({ ...item, value: item.id, label: item.username }))
|
||||
data.users.map((item: Data["users"][0]) => ({
|
||||
...item,
|
||||
value: item.id,
|
||||
label: item.username,
|
||||
}))
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
const randomEmailTwo = generateRandomEmail("somay");
|
||||
const randomEmailThree = generateRandomEmail("rajiv");
|
||||
const randomEmailFour = generateRandomEmail("morgan");
|
||||
const randomEmailFive = generateRandomEmail("lauris");
|
||||
if (!router.isReady) return;
|
||||
|
||||
if (seeding) return;
|
||||
|
||||
if (oAuth2Mode && !authorizationCode) return;
|
||||
|
||||
seeding = true;
|
||||
|
||||
if (oAuth2Mode) {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
|
||||
fetch("/api/oauth2-user", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: randomEmailOne,
|
||||
authorizationCode,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
setAccessToken(data.accessToken);
|
||||
setUserEmail(data.email);
|
||||
setUsername(data.username);
|
||||
});
|
||||
} else {
|
||||
const randomEmailOne = generateRandomEmail("keith");
|
||||
const randomEmailTwo = generateRandomEmail("somay");
|
||||
const randomEmailThree = generateRandomEmail("rajiv");
|
||||
const randomEmailFour = generateRandomEmail("morgan");
|
||||
const randomEmailFive = generateRandomEmail("lauris");
|
||||
|
||||
if (!seeding) {
|
||||
seeding = true;
|
||||
fetch("/api/managed-user", {
|
||||
method: "POST",
|
||||
|
||||
body: JSON.stringify({
|
||||
emails: [randomEmailOne, randomEmailTwo, randomEmailThree, randomEmailFour, randomEmailFive],
|
||||
}),
|
||||
@@ -71,7 +108,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
setUsername(data.username);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [router.isReady, oAuth2Mode, authorizationCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUser) {
|
||||
setAccessToken(selectedUser.accessToken);
|
||||
@@ -84,27 +122,48 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<div className={`${poppins.className} text-black`}>
|
||||
{options.length > 0 && (
|
||||
<Select
|
||||
defaultValue={options.find((opt: TUser | null) => opt?.email.includes("lauris"))}
|
||||
defaultValue={options.find((opt: TUser | null) => opt?.email?.includes("lauris"))}
|
||||
onChange={(opt: TUser | null) => setSelectedUser(opt)}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
<CalProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
|
||||
options={{ apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "", refreshUrl: "/api/refresh" }}>
|
||||
{email ? (
|
||||
<>
|
||||
|
||||
{oAuth2Mode ? (
|
||||
<CalOAuthProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}
|
||||
options={{
|
||||
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
|
||||
refreshUrl: "/api/refresh",
|
||||
}}
|
||||
>
|
||||
{email ? (
|
||||
<Component {...pageProps} calUsername={username} calEmail={email} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<main className={`flex min-h-screen flex-col items-center justify-between p-24 `}>
|
||||
) : (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex" />
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</CalProvider>{" "}
|
||||
)}
|
||||
</CalOAuthProvider>
|
||||
) : (
|
||||
<CalProvider
|
||||
accessToken={accessToken}
|
||||
clientId={process.env.NEXT_PUBLIC_X_CAL_ID ?? ""}
|
||||
options={{
|
||||
apiUrl: process.env.NEXT_PUBLIC_CALCOM_API_URL ?? "",
|
||||
refreshUrl: "/api/refresh",
|
||||
}}
|
||||
>
|
||||
{email ? (
|
||||
<Component {...pageProps} calUsername={username} calEmail={email} />
|
||||
) : (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex" />
|
||||
</main>
|
||||
)}
|
||||
</CalProvider>
|
||||
)}
|
||||
|
||||
{pathname === "/embed" && (
|
||||
<div>
|
||||
<BookerEmbed
|
||||
@@ -129,9 +188,10 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pathname === "/router" && (
|
||||
<div className="p-4">
|
||||
<Router
|
||||
<CalRouter
|
||||
formId="a63e6fce-899a-404e-8c38-e069710589c5"
|
||||
formResponsesURLParams={new URLSearchParams({ isBookingDryRun: "true", Territory: "Europe" })}
|
||||
onDisplayBookerEmbed={() => {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type Data = {
|
||||
// example endpoint to endpoint to fetch managed users
|
||||
export default async function handler(_req: NextApiRequest, res: NextApiResponse<Data>) {
|
||||
const existingUsers = await prisma.user.findMany({ orderBy: { createdAt: "desc" } });
|
||||
if (existingUsers && existingUsers.length > 2) {
|
||||
if (existingUsers && existingUsers.length) {
|
||||
return res.status(200).json({
|
||||
users: existingUsers.map((item) => ({
|
||||
id: item.calcomUserId,
|
||||
@@ -0,0 +1,103 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import prisma from "../../lib/prismaClient";
|
||||
|
||||
type Data = {
|
||||
email: string;
|
||||
username: string;
|
||||
id: number;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
// example endpoint to create a managed cal.com user
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
|
||||
const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
||||
const { email, authorizationCode } = body;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
calcomUserId: true,
|
||||
email: true,
|
||||
calcomUsername: true,
|
||||
accessToken: true,
|
||||
},
|
||||
});
|
||||
if (existingUser && existingUser.calcomUserId) {
|
||||
return res.status(200).json({
|
||||
id: existingUser.calcomUserId,
|
||||
email: existingUser.email,
|
||||
username: existingUser.calcomUsername ?? "",
|
||||
accessToken: existingUser.accessToken ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const oAuthUser = await createOAuthUser(
|
||||
authorizationCode,
|
||||
email,
|
||||
"Keith",
|
||||
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
);
|
||||
|
||||
|
||||
return res.status(200).json(oAuthUser);
|
||||
}
|
||||
|
||||
async function createOAuthUser(authorizationCode:string, email: string, name: string, avatarUrl: string) {
|
||||
const localUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const exchangeResponse = await fetch(
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/exchange`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
origin: "http://localhost:4321",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authorizationCode,
|
||||
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
|
||||
redirectUri: process.env.OAUTH2_REDIRECT_URI,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const exchangeResponseBody = await exchangeResponse.json();
|
||||
|
||||
const acccessToken = exchangeResponseBody.data.access_token;
|
||||
const refreshToken = exchangeResponseBody.data.refresh_token;
|
||||
|
||||
const me = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/me`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
Authorization: `Bearer ${acccessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const meResponseBody = await me.json();
|
||||
|
||||
await prisma.user.update({
|
||||
data: {
|
||||
refreshToken: refreshToken ?? "",
|
||||
accessToken: acccessToken ?? "",
|
||||
calcomUserId: meResponseBody.data?.id,
|
||||
calcomUsername: (meResponseBody.data?.username as string) ?? "",
|
||||
},
|
||||
where: { id: localUser.id },
|
||||
});
|
||||
|
||||
return {...meResponseBody.data, accessToken: acccessToken};
|
||||
}
|
||||
@@ -22,6 +22,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
|
||||
},
|
||||
});
|
||||
if (localUser?.refreshToken) {
|
||||
|
||||
if (process.env.NEXT_PUBLIC_OAUTH2_MODE === "true") {
|
||||
const oAuth2Request = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/auth/oauth2/clients/${process.env.NEXT_PUBLIC_OAUTH2_CLIENT_ID}/refresh`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: localUser.refreshToken,
|
||||
clientSecret: process.env.OAUTH2_CLIENT_SECRET_PLAIN,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (oAuth2Request.status === 200) {
|
||||
const oAuth2Response = await oAuth2Request.json();
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = oAuth2Response.data;
|
||||
|
||||
await prisma.user.update({
|
||||
data: {
|
||||
refreshToken: newRefreshToken as string,
|
||||
accessToken: newAccessToken as string,
|
||||
},
|
||||
where: { id: localUser.id },
|
||||
});
|
||||
return res.status(200).json({ accessToken: newAccessToken });
|
||||
}
|
||||
|
||||
return res.status(400).json({ accessToken: "" });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth/${
|
||||
@@ -53,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
|
||||
});
|
||||
return res.status(200).json({ accessToken: newAccessToken });
|
||||
}
|
||||
|
||||
return res.status(400).json({ accessToken: "" });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user