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:
Lauris Skraucis
2026-02-04 12:54:15 +01:00
committed by GitHub
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
27 changed files with 663 additions and 65 deletions
@@ -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={() => {
@@ -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: "" });
}
}