diff --git a/apps/web/pages/api/auth/oauth/me.ts b/apps/web/pages/api/auth/oauth/me.ts new file mode 100644 index 0000000000..81aaf6e101 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/me.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const requriedScopes = ["READ_PROFILE"]; + + const account = await isAuthorized(req, requriedScopes); + + if (!account) { + return res.status(401).json({ message: "Unauthorized" }); + } + return res.status(201).json({ username: account.name }); +} diff --git a/apps/web/pages/api/auth/oauth/refreshToken.ts b/apps/web/pages/api/auth/oauth/refreshToken.ts new file mode 100644 index 0000000000..a302cc8bf6 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/refreshToken.ts @@ -0,0 +1,68 @@ +import jwt from "jsonwebtoken"; +import type { NextApiRequest, NextApiResponse } from "next"; +import type { OAuthTokenPayload } from "pages/api/auth/oauth/token"; + +import prisma from "@calcom/prisma"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(405).json({ message: "Invalid method" }); + return; + } + + const refreshToken = req.headers.authorization?.split(" ")[1] || ""; + + const { client_id, client_secret, grant_type } = req.body; + + if (grant_type !== "refresh_token") { + res.status(400).json({ message: "grant type invalid" }); + return; + } + + const [hashedSecret] = generateSecret(client_secret); + + const client = await prisma.oAuthClient.findFirst({ + where: { + clientId: client_id, + clientSecret: hashedSecret, + }, + select: { + redirectUri: true, + }, + }); + + if (!client) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + + let decodedRefreshToken: OAuthTokenPayload; + + try { + decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload; + } catch { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const payload: OAuthTokenPayload = { + userId: decodedRefreshToken.userId, + scope: decodedRefreshToken.scope, + token_type: "Access Token", + clientId: client_id, + }; + + const access_token = jwt.sign(payload, secretKey, { + expiresIn: 1800, // 30 min + }); + + res.status(200).json({ access_token }); +} diff --git a/apps/web/pages/api/auth/oauth/token.ts b/apps/web/pages/api/auth/oauth/token.ts new file mode 100644 index 0000000000..6ad4d1e071 --- /dev/null +++ b/apps/web/pages/api/auth/oauth/token.ts @@ -0,0 +1,104 @@ +import jwt from "jsonwebtoken"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +export type OAuthTokenPayload = { + userId?: number | null; + teamId?: number | null; + token_type: string; + scope: string[]; + clientId: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(405).json({ message: "Invalid method" }); + return; + } + + const { code, client_id, client_secret, grant_type, redirect_uri } = req.body; + + if (grant_type !== "authorization_code") { + res.status(400).json({ message: "grant_type invalid" }); + return; + } + + const [hashedSecret] = generateSecret(client_secret); + + const client = await prisma.oAuthClient.findFirst({ + where: { + clientId: client_id, + clientSecret: hashedSecret, + }, + select: { + redirectUri: true, + }, + }); + + if (!client || client.redirectUri !== redirect_uri) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const accessCode = await prisma.accessCode.findFirst({ + where: { + code: code, + clientId: client_id, + expiresAt: { + gt: new Date(), + }, + }, + }); + + //delete all expired accessCodes + the one that is used here + await prisma.accessCode.deleteMany({ + where: { + OR: [ + { + expiresAt: { + lt: new Date(), + }, + }, + { + code: code, + clientId: client_id, + }, + ], + }, + }); + + if (!accessCode) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + + const payloadAuthToken: OAuthTokenPayload = { + userId: accessCode.userId, + teamId: accessCode.teamId, + scope: accessCode.scopes, + token_type: "Access Token", + clientId: client_id, + }; + + const payloadRefreshToken: OAuthTokenPayload = { + userId: accessCode.userId, + teamId: accessCode.teamId, + scope: accessCode.scopes, + token_type: "Refresh Token", + clientId: client_id, + }; + + const access_token = jwt.sign(payloadAuthToken, secretKey, { + expiresIn: 1800, // 30 min + }); + + const refresh_token = jwt.sign(payloadRefreshToken, secretKey, { + expiresIn: 30 * 24 * 60 * 60, // 30 days + }); + + res.status(200).json({ access_token, refresh_token }); +} diff --git a/apps/web/pages/api/trpc/oAuth/[trpc].ts b/apps/web/pages/api/trpc/oAuth/[trpc].ts new file mode 100644 index 0000000000..d4569d82f3 --- /dev/null +++ b/apps/web/pages/api/trpc/oAuth/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { oAuthRouter } from "@calcom/trpc/server/routers/viewer/oAuth/_router"; + +export default createNextApiHandler(oAuthRouter); diff --git a/apps/web/pages/auth/oauth2/authorize.tsx b/apps/web/pages/auth/oauth2/authorize.tsx new file mode 100644 index 0000000000..6b2c276aac --- /dev/null +++ b/apps/web/pages/auth/oauth2/authorize.tsx @@ -0,0 +1,179 @@ +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useState, useEffect } from "react"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar, Button, Select } from "@calcom/ui"; +import { Plus, Info } from "@calcom/ui/components/icon"; + +import PageWrapper from "@components/PageWrapper"; + +export default function Authorize() { + const { t } = useLocale(); + const { status } = useSession(); + + const router = useRouter(); + const searchParams = useSearchParams(); + + const client_id = searchParams?.get("client_id") as string; + const state = searchParams?.get("state") as string; + const scope = searchParams?.get("scope") as string; + + const queryString = searchParams.toString(); + + const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>(); + const scopes = scope ? scope.toString().split(",") : []; + + const { data: client, isLoading: isLoadingGetClient } = trpc.viewer.oAuth.getClient.useQuery( + { + clientId: client_id as string, + }, + { + enabled: status !== "loading", + } + ); + + const { data, isLoading: isLoadingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery(); + + const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({ + onSuccess: (data) => { + window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; + }, + }); + + const mappedProfiles = data + ? data + .filter((profile) => !profile.readOnly) + .map((profile) => ({ + label: profile.name || profile.slug || "", + value: profile.slug || "", + })) + : []; + + useEffect(() => { + if (mappedProfiles.length > 0) { + setSelectedAccount(mappedProfiles[0]); + } + }, [isLoadingProfiles]); + + useEffect(() => { + if (status === "unauthenticated") { + const urlSearchParams = new URLSearchParams({ + callbackUrl: `auth/oauth2/authorize?${queryString}`, + }); + router.replace(`/auth/login?${urlSearchParams.toString()}`); + } + }, [status]); + + const isLoading = isLoadingGetClient || isLoadingProfiles || status !== "authenticated"; + + if (isLoading) { + return <>; + } + + if (!client) { + return
{t("unauthorized")}
; + } + + return ( +
+
+
+ } + className="items-center" + imageSrc={client.logo} + size="lg" + /> +
+
+
+ Logo +
+
+
+
+

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

+
{t("select_account_team")}
+