-
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"; | ||
| } | ||
|
|
||
| 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", | ||
|
Comment on lines
+5
to
+9
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.
When Useful? React with 👍 / 👎. |
||
| 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; | ||
| 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({ | ||
|
Comment on lines
94
to
98
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.
In the Google refresh flow (lines 94‑98) the return value of Useful? React with 👍 / 👎. |
||
| 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.
When credential sharing is enabled the new helper runs
minimumTokenResponseSchema.safeParse(response)(lines 6‑10). The computed property names[z.string().toString()]and[z.string().optional().toString()]evaluate to the literal strings"[object ZodString]", so the schema now requires the response payload to contain two impossible keys with those names. As a result, every valid token response fails validation andparseRefreshTokenResponsealways throws “Invalid refreshed tokens” wheneverCALCOM_CREDENTIAL_SYNC_ENDPOINTis configured, making the newly added credential‑sync flow unusable. The schema needs to accept arbitrary numeric/extra properties (e.g. by using.passthrough()or.catchall) instead of hard‑coded bogus keys.Useful? React with 👍 / 👎.