-
Notifications
You must be signed in to change notification settings - Fork 0
OAuth credential sync and app integration enhancements #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: oauth-security-base
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
| import z from "zod"; | ||
|
|
||
| import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; | ||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||
| import { symmetricDecrypt } from "@calcom/lib/crypto"; | ||
| import prisma from "@calcom/prisma"; | ||
|
|
||
| const appCredentialWebhookRequestBodySchema = z.object({ | ||
| // UserId of the cal.com user | ||
| userId: z.number().int(), | ||
| appSlug: z.string(), | ||
| // Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY | ||
| keys: z.string(), | ||
| }); | ||
| /** */ | ||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||
| // Check that credential sharing is enabled | ||
| if (!APP_CREDENTIAL_SHARING_ENABLED) { | ||
| return res.status(403).json({ message: "Credential sharing is not enabled" }); | ||
| } | ||
|
|
||
| // Check that the webhook secret matches | ||
| if ( | ||
| req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !== | ||
| process.env.CALCOM_WEBHOOK_SECRET | ||
| ) { | ||
| return res.status(403).json({ message: "Invalid webhook secret" }); | ||
| } | ||
|
|
||
| const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body); | ||
|
|
||
| // Check that the user exists | ||
| const user = await prisma.user.findUnique({ where: { id: reqBody.userId } }); | ||
|
|
||
| if (!user) { | ||
| return res.status(404).json({ message: "User not found" }); | ||
| } | ||
|
|
||
| const app = await prisma.app.findUnique({ | ||
| where: { slug: reqBody.appSlug }, | ||
| select: { slug: true }, | ||
| }); | ||
|
|
||
| if (!app) { | ||
| return res.status(404).json({ message: "App not found" }); | ||
| } | ||
|
|
||
| // Search for the app's slug and type | ||
| const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata]; | ||
|
|
||
| if (!appMetadata) { | ||
| return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" }); | ||
| } | ||
|
|
||
| // Decrypt the keys | ||
| const keys = JSON.parse( | ||
| symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") | ||
| ); | ||
|
|
||
| // Can't use prisma upsert as we don't know the id of the credential | ||
| const appCredential = await prisma.credential.findFirst({ | ||
| where: { | ||
| userId: reqBody.userId, | ||
| appId: appMetadata.slug, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (appCredential) { | ||
| await prisma.credential.update({ | ||
| where: { | ||
| id: appCredential.id, | ||
| }, | ||
| data: { | ||
| key: keys, | ||
| }, | ||
| }); | ||
| return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` }); | ||
| } else { | ||
| await prisma.credential.create({ | ||
| data: { | ||
| key: keys, | ||
| userId: reqBody.userId, | ||
| appId: appMetadata.slug, | ||
| type: appMetadata.type, | ||
| }, | ||
| }); | ||
| return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||
| import { z } from "zod"; | ||||||
|
|
||||||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||||||
|
|
||||||
| const minimumTokenResponseSchema = z.object({ | ||||||
| access_token: z.string(), | ||||||
| // Assume that any property with a number is the expiry | ||||||
| [z.string().toString()]: z.number(), | ||||||
| // Allow other properties in the token response | ||||||
| [z.string().optional().toString()]: z.unknown().optional(), | ||||||
| }); | ||||||
|
|
||||||
| const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => { | ||||||
| let refreshTokenResponse; | ||||||
| if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) { | ||||||
| refreshTokenResponse = minimumTokenResponseSchema.safeParse(response); | ||||||
| } else { | ||||||
| refreshTokenResponse = schema.safeParse(response); | ||||||
| } | ||||||
|
|
||||||
| if (!refreshTokenResponse.success) { | ||||||
| throw new Error("Invalid refreshed tokens were returned"); | ||||||
| } | ||||||
|
|
||||||
| if (!refreshTokenResponse.data.refresh_token) { | ||||||
| refreshTokenResponse.data.refresh_token = "refresh_token"; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛑 Critical: Overwriting a missing refresh_token with the literal string "refresh_token" corrupts stored credentials; when an endpoint omits refresh_token (e.g., Office365), subsequent refreshes will fail after this default is saved. Instead, do not set a placeholder here—let callers preserve the existing refresh_token when absent.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| return refreshTokenResponse; | ||||||
| }; | ||||||
|
|
||||||
| export default parseRefreshTokenResponse; | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||
|
|
||
| const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => { | ||
| // Check that app syncing is enabled and that the credential belongs to a user | ||
| if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) { | ||
| // Customize the payload based on what your endpoint requires | ||
| // The response should only contain the access token and expiry date | ||
| const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, { | ||
| method: "POST", | ||
| body: new URLSearchParams({ | ||
| calcomUserId: userId.toString(), | ||
| appSlug, | ||
| }), | ||
| }); | ||
| return response; | ||
| } else { | ||
| const response = await refreshFunction(); | ||
| return response; | ||
| } | ||
| }; | ||
|
|
||
| export default refreshOAuthTokens; |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,8 @@ import type { | |||||||||||
| } from "@calcom/types/Calendar"; | ||||||||||||
| import type { CredentialPayload } from "@calcom/types/Credential"; | ||||||||||||
|
|
||||||||||||
| import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; | ||||||||||||
| import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; | ||||||||||||
| import { getGoogleAppKeys } from "./getGoogleAppKeys"; | ||||||||||||
| import { googleCredentialSchema } from "./googleCredentialSchema"; | ||||||||||||
|
|
||||||||||||
|
|
@@ -81,11 +83,18 @@ export default class GoogleCalendarService implements Calendar { | |||||||||||
|
|
||||||||||||
| const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => { | ||||||||||||
| try { | ||||||||||||
| const { res } = await myGoogleAuth.refreshToken(googleCredentials.refresh_token); | ||||||||||||
| const res = await refreshOAuthTokens( | ||||||||||||
| async () => { | ||||||||||||
| const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token); | ||||||||||||
| return fetchTokens.res; | ||||||||||||
| }, | ||||||||||||
| "google-calendar", | ||||||||||||
| credential.userId | ||||||||||||
| ); | ||||||||||||
| const token = res?.data; | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| googleCredentials.access_token = token.access_token; | ||||||||||||
| googleCredentials.expiry_date = token.expiry_date; | ||||||||||||
| const key = googleCredentialSchema.parse(googleCredentials); | ||||||||||||
| const key = parseRefreshTokenResponse(googleCredentials, googleCredentialSchema); | ||||||||||||
| await prisma.credential.update({ | ||||||||||||
| where: { id: credential.id }, | ||||||||||||
| data: { key }, | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.