From d778249300fff1a6ab2fc1730de31b207e094b57 Mon Sep 17 00:00:00 2001 From: Hariom Date: Mon, 19 May 2025 14:20:15 +0530 Subject: [PATCH 1/2] persist-access-token-dwd --- .../_utils/oauth/OAuthManager.test.ts | 88 ++- .../app-store/_utils/oauth/OAuthManager.ts | 94 ++- .../_utils/oauth/getCurrentTokenObject.ts | 54 ++ .../oauth/getTokenObjectFromCredential.ts | 3 +- .../_utils/oauth/oAuthManagerHelper.ts | 5 +- .../_utils/oauth/updateTokenObject.ts | 69 ++ .../googlecalendar/lib/CalendarAuth.ts | 281 ++++++++ .../googlecalendar/lib/CalendarService.ts | 229 +------ .../lib/__mocks__/features.repository.ts | 9 + .../lib/__mocks__/getGoogleAppKeys.ts | 11 + .../lib/__mocks__/googleapis.ts | 89 ++- .../__tests__/CalendarService.auth.test.ts | 312 +++++++++ .../{ => __tests__}/CalendarService.test.ts | 600 +----------------- .../googlecalendar/lib/__tests__/utils.ts | 201 ++++++ packages/lib/delegationCredential/server.ts | 12 +- packages/lib/piiFreeData.ts | 8 +- packages/lib/server/repository/credential.ts | 69 ++ .../migration.sql | 5 + packages/prisma/schema.prisma | 2 +- ...ocal-for-delegation-credentials-testing.js | 141 ++++ tests/libs/__mocks__/app-store.ts | 4 +- 21 files changed, 1452 insertions(+), 834 deletions(-) create mode 100644 packages/app-store/_utils/oauth/getCurrentTokenObject.ts create mode 100644 packages/app-store/googlecalendar/lib/CalendarAuth.ts create mode 100644 packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts create mode 100644 packages/app-store/googlecalendar/lib/__mocks__/getGoogleAppKeys.ts create mode 100644 packages/app-store/googlecalendar/lib/__tests__/CalendarService.auth.test.ts rename packages/app-store/googlecalendar/lib/{ => __tests__}/CalendarService.test.ts (62%) create mode 100644 packages/app-store/googlecalendar/lib/__tests__/utils.ts create mode 100644 packages/prisma/migrations/20250525034030_add_index_credential_delegation_credential_id/migration.sql create mode 100644 scripts/prepare-local-for-delegation-credentials-testing.js diff --git a/packages/app-store/_utils/oauth/OAuthManager.test.ts b/packages/app-store/_utils/oauth/OAuthManager.test.ts index b0056504fc7b8f..9870808fcbc8e5 100644 --- a/packages/app-store/_utils/oauth/OAuthManager.test.ts +++ b/packages/app-store/_utils/oauth/OAuthManager.test.ts @@ -189,6 +189,49 @@ describe("Credential Sync Disabled", () => { expiry_date: 0, }); }); + + test("`fetchNewTokenObject` is called if token is about to expire. Also, `updateTokenObject` is called with currentTokenObject and newTokenObject merged", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const currentTokenObject = getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expiry_date: Date.now() - 2 * 1000, + }); + const newTokenObjectInResponse = getDummyTokenObject(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: newTokenObjectInResponse })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: currentTokenObject, + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + expect(updateTokenObject).toHaveBeenCalledWith({ + ...currentTokenObject, + ...newTokenObjectInResponse, + // Consider the token as expired as newTokenObjectInResponse didn't have expiry + expiry_date: 0, + }); + }); }); describe("checking using expires_in", () => { @@ -285,7 +328,7 @@ describe("Credential Sync Disabled", () => { appSlug: "demo-app", currentTokenObject: getDummyTokenObject({ refresh_token: "REFRESH_TOKEN", - expires_in: Date.now() / 1000 + 5, + expires_in: Date.now() / 1000 + 10, }), fetchNewTokenObject, isTokenObjectUnusable: async () => { @@ -388,7 +431,7 @@ describe("Credential Sync Disabled", () => { appSlug: "demo-app", currentTokenObject: getDummyTokenObject(), fetchNewTokenObject: async () => { - throw new Error("testError"); + throw new Error("fetchNewTokenObject error"); }, isTokenObjectUnusable: async () => { return null; @@ -403,7 +446,7 @@ describe("Credential Sync Disabled", () => { expect(async () => { return auth.getTokenObjectOrFetch(); - }).rejects.toThrowError("Invalid token response"); + }).rejects.toThrowError("fetchNewTokenObject error"); }); test("if fetchNewTokenObject throws error that's handled by isTokenObjectUnusable then auth.getTokenObjectOrFetch would still throw error but a different one as access_token won't be available", async () => { @@ -421,7 +464,7 @@ describe("Credential Sync Disabled", () => { appSlug: "demo-app", currentTokenObject: getDummyTokenObject(), fetchNewTokenObject: async () => { - throw new Error("testError"); + throw new Error("fetchNewTokenObject error"); }, isTokenObjectUnusable: async () => { return { @@ -438,7 +481,42 @@ describe("Credential Sync Disabled", () => { expect(async () => { return auth.getTokenObjectOrFetch(); - }).rejects.toThrowError("Invalid token response"); + }).rejects.toThrowError("fetchNewTokenObject error"); + }); + + test("when currentTokenObject is not set, but getCurrentTokenObject is set", async () => { + const userId = 1; + const getCurrentTokenObject = vi.fn().mockResolvedValue(getDummyTokenObject()); + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + isTokenExpiring: async () => { + return false; + }, + isAccessTokenUnusable: async () => { + return null; + }, + getCurrentTokenObject: getCurrentTokenObject, + fetchNewTokenObject: async () => { + return generateJsonResponse({ json: getDummyTokenObject() }); + }, + updateTokenObject: vi.fn(), + invalidateTokenObject: vi.fn(), + expireAccessToken: vi.fn(), + isTokenObjectUnusable: async () => { + return null; + }, + }); + const tokenObject = await auth.getTokenObjectOrFetch(); + expect(getCurrentTokenObject).toHaveBeenCalled(); + expect(tokenObject).toEqual({ + token: expect.objectContaining(getDummyTokenObject()), + isUpdated: false, + }); }); }); diff --git a/packages/app-store/_utils/oauth/OAuthManager.ts b/packages/app-store/_utils/oauth/OAuthManager.ts index 47942f9b751f57..91893208eb7949 100644 --- a/packages/app-store/_utils/oauth/OAuthManager.ts +++ b/packages/app-store/_utils/oauth/OAuthManager.ts @@ -58,20 +58,24 @@ type CredentialSyncVariables = { APP_CREDENTIAL_SHARING_ENABLED: boolean; }; + +type CurrentTokenObject = z.infer; +type GetCurrentTokenObject = () => Promise; /** * Manages OAuth2.0 tokens for an app and resourceOwner * If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled) * If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired) */ export class OAuthManager { - private currentTokenObject: z.infer; + protected currentTokenObject: CurrentTokenObject | null; + private getCurrentTokenObject: GetCurrentTokenObject | null; private resourceOwner: ResourceOwner; private appSlug: string; private fetchNewTokenObject: FetchNewTokenObject; private updateTokenObject: UpdateTokenObject; private isTokenObjectUnusable: isTokenObjectUnusable; private isAccessTokenUnusable: isAccessTokenUnusable; - private isTokenExpired: IsTokenExpired; + private isTokenExpiring: IsTokenExpired; private invalidateTokenObject: InvalidateTokenObject; private expireAccessToken: ExpireAccessToken; private credentialSyncVariables: CredentialSyncVariables; @@ -79,6 +83,7 @@ export class OAuthManager { private autoCheckTokenExpiryOnRequest: boolean; constructor({ + getCurrentTokenObject, resourceOwner, appSlug, currentTokenObject, @@ -90,14 +95,22 @@ export class OAuthManager { expireAccessToken, credentialSyncVariables, autoCheckTokenExpiryOnRequest = true, - isTokenExpired = (token: z.infer) => { + isTokenExpiring = (token: z.infer) => { + // TODO: Make it configurable later + // 5 seconds before expiry so that we can refresh the token before any request is made with the expired token + const expireThreshold = 5000; + const isGoingToExpire = getExpiryDate() - expireThreshold <= Date.now(); log.debug( - "isTokenExpired called", - safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() }) + "isTokenExpiring", + safeStringify({ + isGoingToExpire, + expiry_date: token.expiry_date, + expires_in: token.expires_in, + currentTime: Date.now(), + expireThreshold, + }) ); - - return getExpiryDate() <= Date.now(); - + return isGoingToExpire; function isRelativeToEpoch(relativeTimeInSeconds: number) { return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time } @@ -137,11 +150,14 @@ export class OAuthManager { /** * The current token object. */ - currentTokenObject: z.infer; + currentTokenObject?: CurrentTokenObject; + + getCurrentTokenObject?: GetCurrentTokenObject; /** * The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting */ appSlug: string; + /** * * It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened. @@ -172,15 +188,19 @@ export class OAuthManager { /** * If there is a different way to check if the token is expired(and not the standard way of checking expiry_date) */ - isTokenExpired?: IsTokenExpired; + isTokenExpiring?: IsTokenExpired; }) { + if (!getCurrentTokenObject && !currentTokenObject) { + throw new Error("One of getCurrentTokenObject or currentTokenObject is required"); + } this.resourceOwner = resourceOwner; - this.currentTokenObject = currentTokenObject; + this.currentTokenObject = currentTokenObject ?? null; + this.getCurrentTokenObject = getCurrentTokenObject ?? null; this.appSlug = appSlug; this.fetchNewTokenObject = fetchNewTokenObject; this.isTokenObjectUnusable = isTokenObjectUnusable; this.isAccessTokenUnusable = isAccessTokenUnusable; - this.isTokenExpired = isTokenExpired; + this.isTokenExpiring = isTokenExpiring; this.invalidateTokenObject = invalidateTokenObject; this.expireAccessToken = expireAccessToken; this.credentialSyncVariables = credentialSyncVariables; @@ -205,14 +225,28 @@ export class OAuthManager { return !response.ok || response.status < 200 || response.status >= 300; } + /** + * Gets the current token object as is if not expired. + * If expired, it refreshes the token and returns the new token object. + * It also calls the `updateTokenObject` to update the token object in the database if it is changed. + */ public async getTokenObjectOrFetch() { const myLog = log.getSubLogger({ prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`], }); - const isExpired = await this.isTokenExpired(this.currentTokenObject); + let currentTokenObject; + if (this.currentTokenObject) { + currentTokenObject = this.currentTokenObject; + } else if (this.getCurrentTokenObject) { + this.currentTokenObject = currentTokenObject = await this.getCurrentTokenObject(); + } else { + throw new Error("Neither currentTokenObject nor getCurrentTokenObject is set"); + } + const isExpired = await this.isTokenExpiring(currentTokenObject); myLog.debug( "getTokenObjectOrFetch called", safeStringify({ + currentTokenObjectHasAccessToken: !!currentTokenObject.access_token, isExpired, resourceOwner: this.resourceOwner, }) @@ -220,14 +254,16 @@ export class OAuthManager { if (!isExpired) { myLog.debug("Token is not expired. Returning the current token object"); - return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false }; + return { token: this.normalizeNewlyReceivedToken(currentTokenObject), isUpdated: false }; } else { const token = { // Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar // It also allows any other properties set to be retained. // Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent - ...this.currentTokenObject, - ...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()), + ...currentTokenObject, + ...this.normalizeNewlyReceivedToken( + await this.refreshOAuthToken({ refreshToken: currentTokenObject.refresh_token ?? null }) + ), }; myLog.debug("Token is expired. So, returning new token object"); this.currentTokenObject = token; @@ -268,7 +304,6 @@ export class OAuthManager { ) { let response; const myLog = log.getSubLogger({ prefix: ["request"] }); - if (this.autoCheckTokenExpiryOnRequest) { await this.getTokenObjectOrFetch(); } @@ -284,6 +319,7 @@ export class OAuthManager { response = handleFetchError(e); } } else { + this.assertCurrentTokenObjectIsSet(); const { url, options } = customFetchOrUrlAndOptions; const headers = { Authorization: `Bearer ${this.currentTokenObject.access_token}`, @@ -330,6 +366,16 @@ export class OAuthManager { return { tokenStatus: tokenStatus, json }; } + /** + * currentTokenObject is set through getTokenObjectOrFetch call + */ + private assertCurrentTokenObjectIsSet(): asserts this is this & { + currentTokenObject: CurrentTokenObject; + } { + if (!this.currentTokenObject) { + throw new Error("currentTokenObject is not set"); + } + } /** * It doesn't automatically detect the response for tokenObject and accessToken becoming invalid * Could be used when you expect a possible non JSON response as well. @@ -340,6 +386,9 @@ export class OAuthManager { if (this.autoCheckTokenExpiryOnRequest) { await this.getTokenObjectOrFetch(); } + // Either `getTokenObjectOrFetch` has been called through autoCheckTokenExpiryOnRequest or through a direct call to it outside OAuthManager + // In both cases, `currentTokenObject` is set + this.assertCurrentTokenObjectIsSet(); const headers = { Authorization: `Bearer ${this.currentTokenObject.access_token}`, "Content-Type": "application/json", @@ -409,10 +458,9 @@ export class OAuthManager { } // TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working - private async refreshOAuthToken() { + private async refreshOAuthToken({ refreshToken }: { refreshToken: string | null }) { const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] }); let response; - const refreshToken = this.currentTokenObject.refresh_token ?? null; if (this.resourceOwner.id && this.useCredentialSync) { if ( !this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET || @@ -468,9 +516,8 @@ export class OAuthManager { const clonedResponse = response.clone(); myLog.info( - "Response from refreshOAuthToken", + "Response status from refreshOAuthToken", safeStringify({ - text: await clonedResponse.text(), ok: clonedResponse.ok, status: clonedResponse.status, statusText: clonedResponse.statusText, @@ -485,6 +532,11 @@ export class OAuthManager { } else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) { await this.expireAccessToken(); } + + if (json && json.myFetchError) { + // Throw error back as it isn't a valid token response and we can't process it further + throw new Error(json.myFetchError); + } const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json); if (!parsedToken.success) { myLog.error( diff --git a/packages/app-store/_utils/oauth/getCurrentTokenObject.ts b/packages/app-store/_utils/oauth/getCurrentTokenObject.ts new file mode 100644 index 00000000000000..613f91ff197b3f --- /dev/null +++ b/packages/app-store/_utils/oauth/getCurrentTokenObject.ts @@ -0,0 +1,54 @@ +import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer"; +import logger from "@calcom/lib/logger"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; + +import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential"; + +const log = logger.getSubLogger({ + prefix: ["getCurrentTokenObject"], +}); + +function buildDummyTokenObjectForDelegationUserCredential() { + return { + access_token: "TOKEN_PLACEHOLDER_FOR_DELEGATION_CREDENTIAL", + }; +} + +/** + * OAuthManager helper to get the current token object. It decides to use the Credential that is passed or retrieve from db. + */ +export async function getCurrentTokenObject( + credential: Pick +) { + let inDbCredential; + // CalendarService currently receives an in-memory delegation credential which is incapable to persist access token generated for a user. + // So, in this case we read Delegation User Credential from db separately if available. updateTokenObject will create new Delegation User Credential in db if not available. + if (credential.delegatedToId && isInMemoryDelegationCredential({ credentialId: credential.id })) { + if (!credential.userId) { + log.error("DelegationCredential: No user id found for delegation credential"); + } else { + log.debug("Getting current token object for delegation credential"); + const delegationUserCredentialInDb = + await CredentialRepository.findUniqueByUserIdAndDelegationCredentialId({ + userId: credential.userId, + delegationCredentialId: credential.delegatedToId, + }); + inDbCredential = delegationUserCredentialInDb; + if (!inDbCredential) { + log.error("getCurrentTokenObject: No delegation user credential found in db"); + // We return a dummy token object. OAuthManager requires a token object that must have access_token. + // OAuthManager will help fetching new token object and then that would be stored in DB. + return buildDummyTokenObjectForDelegationUserCredential(); + } + } + } else { + log.debug("Getting current token object for non delegation credential"); + inDbCredential = credential; + } + if (!inDbCredential) { + throw new Error("getCurrentTokenObject: No delegation user credential found in db"); + } + const currentTokenObject = getTokenObjectFromCredential(inDbCredential); + return currentTokenObject; +} diff --git a/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts b/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts index 2560f790297bbc..1eff1920e1c60d 100644 --- a/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts +++ b/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts @@ -4,9 +4,8 @@ import type { CredentialPayload } from "@calcom/types/Credential"; import { OAuth2TokenResponseInDbSchema } from "./universalSchema"; -export function getTokenObjectFromCredential(credential: CredentialPayload) { +export function getTokenObjectFromCredential(credential: Pick) { const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key); - if (!parsedTokenResponse.success) { logger.error( "GoogleCalendarService-getTokenObjectFromCredential", diff --git a/packages/app-store/_utils/oauth/oAuthManagerHelper.ts b/packages/app-store/_utils/oauth/oAuthManagerHelper.ts index 83f81827b387d0..5cd7b153ec5ce4 100644 --- a/packages/app-store/_utils/oauth/oAuthManagerHelper.ts +++ b/packages/app-store/_utils/oauth/oAuthManagerHelper.ts @@ -6,9 +6,10 @@ import { } from "@calcom/lib/constants"; import { invalidateCredential } from "../invalidateCredential"; +import { getCurrentTokenObject } from "./getCurrentTokenObject"; import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential"; import { markTokenAsExpired } from "./markTokenAsExpired"; -import { updateTokenObject } from "./updateTokenObject"; +import { updateTokenObject, updateTokenObjectInDb } from "./updateTokenObject"; export const credentialSyncVariables = { APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, @@ -23,4 +24,6 @@ export const oAuthManagerHelper = { invalidateCredential: invalidateCredential, getTokenObjectFromCredential, credentialSyncVariables, + updateTokenObjectInDb, + getCurrentTokenObject, }; diff --git a/packages/app-store/_utils/oauth/updateTokenObject.ts b/packages/app-store/_utils/oauth/updateTokenObject.ts index 922495e152cc0e..c5d8fb5a4170b5 100644 --- a/packages/app-store/_utils/oauth/updateTokenObject.ts +++ b/packages/app-store/_utils/oauth/updateTokenObject.ts @@ -1,9 +1,16 @@ +import type { Prisma } from "@prisma/client"; import type z from "zod"; +import logger from "@calcom/lib/logger"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import prisma from "@calcom/prisma"; import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema"; +const log = logger.getSubLogger({ prefix: ["_utils", "oauth", "updateTokenObject"] }); +/** + * @deprecated Use updateTokenObjectInDb instead + */ export const updateTokenObject = async ({ tokenObject, credentialId, @@ -20,3 +27,65 @@ export const updateTokenObject = async ({ }, }); }; + +/** + * OAuthManager helper to update the token object in db. + * + * It ensures that the token goes in DB. For JWT flow, it also creates a delegation user credential if not present + */ +export const updateTokenObjectInDb = async ( + args: { + tokenObject: z.infer; + } & ( + | { + authStrategy: "jwt"; + userId: number | null; + credentialType: string; + delegatedToId: string | null; + } + | { + authStrategy: "oauth"; + credentialId: number; + } + ) +) => { + const { tokenObject } = args; + if (args.authStrategy === "jwt") { + const { userId, delegatedToId, credentialType } = args; + if (!userId) { + log.error("Cannot update token object in DB for Delegation as userId is not present"); + return; + } + if (!delegatedToId) { + log.error("Cannot update token object in DB for Delegation as delegatedToId is not present"); + return; + } + + const updated = await CredentialRepository.updateWhereUserIdAndDelegationCredentialId({ + userId, + delegationCredentialId: delegatedToId, + data: { + key: tokenObject as Prisma.InputJsonValue, + }, + }); + + // If no delegation-credential is updated, create one + if (updated.count === 0) { + log.debug("No delegation-credential found. Creating one"); + await CredentialRepository.createDelegationCredential({ + userId, + delegationCredentialId: delegatedToId, + type: credentialType, + key: tokenObject as Prisma.InputJsonValue, + }); + } + } else { + const { credentialId } = args; + await CredentialRepository.updateWhereId({ + id: credentialId, + data: { + key: tokenObject as Prisma.InputJsonValue, + }, + }); + } +}; diff --git a/packages/app-store/googlecalendar/lib/CalendarAuth.ts b/packages/app-store/googlecalendar/lib/CalendarAuth.ts new file mode 100644 index 00000000000000..e35a23698cf461 --- /dev/null +++ b/packages/app-store/googlecalendar/lib/CalendarAuth.ts @@ -0,0 +1,281 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { calendar_v3 } from "@googleapis/calendar"; +import type { Prisma } from "@prisma/client"; +import { OAuth2Client, JWT } from "googleapis-common"; + +import { + CalendarAppDelegationCredentialClientIdNotAuthorizedError, + CalendarAppDelegationCredentialInvalidGrantError, + CalendarAppDelegationCredentialError, +} from "@calcom/lib/CalendarAppError"; +import { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, +} from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; + +import { invalidateCredential } from "../../_utils/invalidateCredential"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; +import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema"; +import { metadata } from "../_metadata"; +import { getGoogleAppKeys } from "./getGoogleAppKeys"; + +type DelegatedTo = NonNullable; +const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarAuth"] }); + +class MyGoogleOAuth2Client extends OAuth2Client { + constructor(client_id: string, client_secret: string, redirect_uri: string) { + super(client_id, client_secret, redirect_uri); + } + + isTokenExpiring() { + return super.isTokenExpiring(); + } + + async refreshToken(token: string | null | undefined) { + return super.refreshToken(token); + } +} + +export class CalendarAuth { + private credential: CredentialForCalendarServiceWithEmail; + private jwtAuthClient: JWT | null = null; + private oAuthClient: MyGoogleOAuth2Client | null = null; + public authManager!: OAuthManager; + private authMechanism: ReturnType; + + constructor(credential: CredentialForCalendarServiceWithEmail) { + this.credential = credential; + this.authMechanism = this.initAuthMechanism(credential); + } + + private getAuthStrategy(): "jwt" | "oauth" { + return this.credential.delegatedToId ? "jwt" : "oauth"; + } + + private async getOAuthClientSingleton() { + if (this.oAuthClient) { + log.debug("Reusing existing oAuthClient"); + return this.oAuthClient; + } + log.debug("Creating new oAuthClient"); + const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); + const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key); + this.oAuthClient = new MyGoogleOAuth2Client(client_id, client_secret, redirect_uris[0]); + this.oAuthClient.setCredentials(googleCredentials); + return this.oAuthClient; + } + + private async getJwtClientSingleton({ + emailToImpersonate, + delegatedTo, + }: { + emailToImpersonate: string | null; + delegatedTo: DelegatedTo; + }) { + if (!emailToImpersonate) { + log.error("DelegatedTo: No email to impersonate found for delegation credential"); + return null; + } + const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/; + if (!this.jwtAuthClient) { + log.debug("Creating new delegation credential authClient"); + const authClient = new JWT({ + email: delegatedTo.serviceAccountKey.client_email, + key: delegatedTo.serviceAccountKey.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: emailToImpersonate.replace(oauthClientIdAliasRegex, ""), + }); + this.jwtAuthClient = authClient; + } else { + log.debug("Reusing existing delegation credential authClient"); + } + return this.jwtAuthClient; + } + + private async refreshOAuthToken({ refreshToken }: { refreshToken: string | null }) { + const oAuthClient = await this.getOAuthClientSingleton(); + return oAuthClient.refreshToken(refreshToken); + } + + private refreshJwtToken = async ({ delegatedTo }: { delegatedTo: DelegatedTo }) => { + log.debug("Attempting to authorize using JWT auth"); + const { user } = this.credential; + const emailToImpersonate = user?.email ?? null; + const authClient = await this.getJwtClientSingleton({ delegatedTo, emailToImpersonate }); + if (!authClient) { + log.error("JWT auth: No auth client found"); + return null; + } + try { + log.debug("Authorizing using JWT auth"); + return await authClient.authorize(); + } catch (error) { + log.error("DelegatedTo: Error authorizing using JWT auth", JSON.stringify(error)); + + if ((error as any).response?.data?.error === "unauthorized_client") { + throw new CalendarAppDelegationCredentialClientIdNotAuthorizedError( + "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" + ); + } + + if ((error as any).response?.data?.error === "invalid_grant") { + throw new CalendarAppDelegationCredentialInvalidGrantError( + `User ${emailToImpersonate} might not exist in Google Workspace` + ); + } + + // Catch all error + throw new CalendarAppDelegationCredentialError("Error authorizing delegation credential"); + } + }; + + private initAuthMechanism(credential: CredentialForCalendarServiceWithEmail) { + const authStrategy = this.getAuthStrategy(); + const authManager = new OAuthManager({ + // Keep it false for oauth because Google's OAuth2Client library that we use supports token expiry check, itself when we use the client to make any request + // We keep it true for jwt because JWT Client doesn't support token expiry check and we do it ourselves + autoCheckTokenExpiryOnRequest: authStrategy !== "oauth", + ...(authStrategy === "oauth" + ? { + // Use Google's OAuth2Client library itself to check if the token is expiring + // For JWT, OAuthManager will do it itself + isTokenExpiring: async () => { + const oAuthClient = await this.getOAuthClientSingleton(); + return oAuthClient.isTokenExpiring(); + }, + } + : {}), + credentialSyncVariables: { + APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME, + }, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: metadata.slug, + getCurrentTokenObject: async () => { + return oAuthManagerHelper.getCurrentTokenObject(this.credential); + }, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + let result; + if (authStrategy === "jwt" && this.credential.delegatedTo) { + log.debug("Fetching new token object for JWT auth"); + result = { + // In case of JWT Token flow, there is no refresh token, so we need to refresh the token using Service Account + tokenObject: await this.refreshJwtToken({ delegatedTo: this.credential.delegatedTo }), + status: 200, + statusText: "OK", + }; + } + + if (!result || !result.tokenObject) { + log.debug("Fetching new token object for my Google Auth"); + const tokenFetchedResult = await this.refreshOAuthToken({ refreshToken }); + result = { + tokenObject: tokenFetchedResult.res?.data ?? null, + status: tokenFetchedResult.res?.status, + statusText: tokenFetchedResult.res?.statusText, + }; + } + return new Response(JSON.stringify(result.tokenObject), { + status: result.status, + statusText: result.statusText, + }); + }, + isTokenObjectUnusable: async function (response) { + // TODO: Confirm that if this logic should go to isAccessTokenUnusable + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + if (responseBody.error === "invalid_grant") { + return { + reason: "invalid_grant", + }; + } + } + return null; + }, + isAccessTokenUnusable: async () => { + // As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected + // So, a situation where access_token is invalid but refresh_token is valid should not happen + return null; + }, + invalidateTokenObject: () => invalidateCredential(this.credential.id), + expireAccessToken: async () => { + await oAuthManagerHelper.markTokenAsExpired(this.credential); + }, + updateTokenObject: async (token) => { + await oAuthManagerHelper.updateTokenObjectInDb({ + tokenObject: token, + authStrategy: this.getAuthStrategy(), + credentialId: this.credential.id, + userId: this.credential.userId ?? null, + delegatedToId: this.credential.delegatedToId ?? null, + credentialType: this.credential.type, + }); + if (this.oAuthClient) { + this.oAuthClient.setCredentials(token); + } + + // Update cached credential as well + this.credential.key = token as Prisma.JsonValue; + }, + }); + this.authManager = authManager; + + return { + getOAuthClientWithRefreshedToken: async () => { + const { token } = await authManager.getTokenObjectOrFetch(); + if (!token) { + throw new Error("Invalid grant for Google Calendar app"); + } + const oAuthClient = await this.getOAuthClientSingleton(); + return oAuthClient; + }, + getJwtClientWithRefreshedToken: async ({ delegatedTo }: { delegatedTo: DelegatedTo }) => { + log.debug("Getting JWT client with refreshed token"); + await authManager.getTokenObjectOrFetch(); + return this.getJwtClientSingleton({ + emailToImpersonate: this.credential.user?.email ?? null, + delegatedTo, + }); + }, + }; + } + + /** + * Returns a Google Calendar client that is authenticated with the user's credentials. + * If the user is delegated, it will use the delegation credential. + * If the user is not delegated, it will use the user's OAuth credentials. + */ + public async getClient(): Promise { + log.debug("Getting authed calendar client"); + let googleAuthClient; + const authStrategy = this.getAuthStrategy(); + + if (authStrategy === "jwt" && this.credential.delegatedTo) { + googleAuthClient = await this.authMechanism.getJwtClientWithRefreshedToken({ + delegatedTo: this.credential.delegatedTo, + }); + } + + if (!googleAuthClient) { + googleAuthClient = await this.authMechanism.getOAuthClientWithRefreshedToken(); + } + + if (!googleAuthClient) { + throw new Error("Failed to initialize Google Auth client"); + } + + return new calendar_v3.Calendar({ + auth: googleAuthClient, + }); + } +} diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 3e4be621fe1978..4b3c2672d69ec4 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { calendar_v3 } from "@googleapis/calendar"; +import type { calendar_v3 } from "@googleapis/calendar"; import type { Prisma } from "@prisma/client"; -import { OAuth2Client, JWT } from "googleapis-common"; import type { GaxiosResponse } from "googleapis-common"; import { RRule } from "rrule"; import { v4 as uuid } from "uuid"; @@ -12,18 +11,7 @@ import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; -import { - CalendarAppDelegationCredentialClientIdNotAuthorizedError, - CalendarAppDelegationCredentialInvalidGrantError, - CalendarAppDelegationCredentialError, -} from "@calcom/lib/CalendarAppError"; import { uniqueBy } from "@calcom/lib/array"; -import { - APP_CREDENTIAL_SHARING_ENABLED, - CREDENTIAL_SYNC_ENDPOINT, - CREDENTIAL_SYNC_SECRET, - CREDENTIAL_SYNC_SECRET_HEADER_NAME, -} from "@calcom/lib/constants"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -39,14 +27,8 @@ import type { import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; -import { invalidateCredential } from "../../_utils/invalidateCredential"; import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse"; -import { OAuthManager } from "../../_utils/oauth/OAuthManager"; -import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; -import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired"; -import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema"; -import { metadata } from "../_metadata"; -import { getGoogleAppKeys } from "./getGoogleAppKeys"; +import { CalendarAuth } from "./CalendarAuth"; const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); @@ -73,15 +55,14 @@ type GoogleChannelProps = { export default class GoogleCalendarService implements Calendar { private integrationName = ""; - private auth: ReturnType; + private auth: CalendarAuth; private log: typeof logger; private credential: CredentialForCalendarServiceWithEmail; - private myGoogleAuth!: MyGoogleAuth; - private oAuthManagerInstance!: OAuthManager; + constructor(credential: CredentialForCalendarServiceWithEmail) { this.integrationName = "google_calendar"; this.credential = credential; - this.auth = this.initGoogleAuth(credential); + this.auth = new CalendarAuth(credential); this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } @@ -89,186 +70,11 @@ export default class GoogleCalendarService implements Calendar { return this.credential.id; } - private async getMyGoogleAuthSingleton() { - if (this.myGoogleAuth) { - return this.myGoogleAuth; - } - const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); - const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key); - this.myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); - this.myGoogleAuth.setCredentials(googleCredentials); - return this.myGoogleAuth; + public async authedCalendar(): Promise { + this.log.debug("Getting authed calendar"); + return this.auth.getClient(); } - private initGoogleAuth = (credential: CredentialForCalendarServiceWithEmail) => { - const currentTokenObject = getTokenObjectFromCredential(credential); - const auth = new OAuthManager({ - // Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well. - autoCheckTokenExpiryOnRequest: false, - credentialSyncVariables: { - APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, - CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT, - CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET, - CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME, - }, - resourceOwner: { - type: "user", - id: credential.userId, - }, - appSlug: metadata.slug, - currentTokenObject, - fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { - const myGoogleAuth = await this.getMyGoogleAuthSingleton(); - const fetchTokens = await myGoogleAuth.refreshToken(refreshToken); - // Create Response from fetchToken.res - const response = new Response(JSON.stringify(fetchTokens.res?.data ?? null), { - status: fetchTokens.res?.status, - statusText: fetchTokens.res?.statusText, - }); - return response; - }, - isTokenExpired: async () => { - const myGoogleAuth = await this.getMyGoogleAuthSingleton(); - return myGoogleAuth.isTokenExpiring(); - }, - isTokenObjectUnusable: async function (response) { - // TODO: Confirm that if this logic should go to isAccessTokenUnusable - if (!response.ok || (response.status < 200 && response.status >= 300)) { - const responseBody = await response.json(); - - if (responseBody.error === "invalid_grant") { - return { - reason: "invalid_grant", - }; - } - } - return null; - }, - isAccessTokenUnusable: async () => { - // As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected - // So, a situation where access_token is invalid but refresh_token is valid should not happen - return null; - }, - invalidateTokenObject: () => invalidateCredential(this.credential.id), - expireAccessToken: async () => { - await markTokenAsExpired(this.credential); - }, - updateTokenObject: async (token) => { - this.myGoogleAuth.setCredentials(token); - - const { key } = await prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: token, - }, - }); - - // Update cached credential as well - this.credential.key = key; - }, - }); - this.oAuthManagerInstance = auth; - return { - getMyGoogleAuthWithRefreshedToken: async () => { - // It would automatically update myGoogleAuth with correct token - const { token } = await auth.getTokenObjectOrFetch(); - if (!token) { - throw new Error("Invalid grant for Google Calendar app"); - } - - const myGoogleAuth = await this.getMyGoogleAuthSingleton(); - return myGoogleAuth; - }, - }; - }; - - private getAuthedCalendarFromDelegationCredential = async ({ - delegationCredential, - emailToImpersonate, - }: { - emailToImpersonate: string; - delegationCredential: { - serviceAccountKey: { - client_email: string; - client_id: string; - private_key: string; - }; - }; - }) => { - const serviceAccountClientEmail = delegationCredential.serviceAccountKey.client_email; - const serviceAccountClientId = delegationCredential.serviceAccountKey.client_id; - const serviceAccountPrivateKey = delegationCredential.serviceAccountKey.private_key; - - const authClient = new JWT({ - email: serviceAccountClientEmail, - key: serviceAccountPrivateKey, - scopes: ["https://www.googleapis.com/auth/calendar"], - subject: emailToImpersonate, - }); - - try { - await authClient.authorize(); - } catch (error) { - this.log.error("DelegationCredential: Error authorizing delegation credential", JSON.stringify(error)); - - if ((error as any).response?.data?.error === "unauthorized_client") { - throw new CalendarAppDelegationCredentialClientIdNotAuthorizedError( - "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" - ); - } - - if ((error as any).response?.data?.error === "invalid_grant") { - throw new CalendarAppDelegationCredentialInvalidGrantError( - `User ${emailToImpersonate} might not exist in Google Workspace` - ); - } - - // Catch all error - throw new CalendarAppDelegationCredentialError("Error authorizing delegation credential"); - } - - this.log.debug( - "Using delegation credential with service account email", - safeStringify({ - serviceAccountClientEmail, - serviceAccountClientId, - emailToImpersonate, - }) - ); - - return new calendar_v3.Calendar({ - auth: authClient, - }); - }; - - public authedCalendar = async () => { - let delegationCredentialAuthedCalendar; - - if (this.credential.delegatedTo) { - if (!this.credential.user?.email) { - this.log.error("DelegationCredential: No email to impersonate found for delegation credential"); - } else { - const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/; - delegationCredentialAuthedCalendar = await this.getAuthedCalendarFromDelegationCredential({ - delegationCredential: this.credential.delegatedTo, - emailToImpersonate: this.credential.user.email.replace(oauthClientIdAliasRegex, ""), - }); - } - } - - if (delegationCredentialAuthedCalendar) { - return delegationCredentialAuthedCalendar; - } - - const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken(); - const calendar = new calendar_v3.Calendar({ - auth: myGoogleAuth, - }); - return calendar; - }; - private getAttendees = ({ event, hostExternalCalendarId, @@ -656,7 +462,7 @@ export default class GoogleCalendarService implements Calendar { async fetchAvailability(requestBody: FreeBusyArgs): Promise { log.debug("fetchAvailability", safeStringify({ requestBody })); const calendar = await this.authedCalendar(); - const apiResponse = await this.oAuthManagerInstance.request( + const apiResponse = await this.auth.authManager.request( async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody })) ); return apiResponse.json; @@ -896,7 +702,7 @@ export default class GoogleCalendarService implements Calendar { this.log.debug("Listing calendars"); const calendar = await this.authedCalendar(); try { - const { json: cals } = await this.oAuthManagerInstance.request( + const { json: cals } = await this.auth.authManager.request( async () => new AxiosLikeResponseToFetchResponse({ status: 200, @@ -927,6 +733,7 @@ export default class GoogleCalendarService implements Calendar { // It would error if the delegation credential is not set up correctly async testDelegationCredentialSetup() { + log.debug("Testing delegation credential setup"); const calendar = await this.authedCalendar(); const cals = await calendar.calendarList.list({ fields: "items(id)" }); return !!cals.data.items; @@ -1250,17 +1057,3 @@ export default class GoogleCalendarService implements Calendar { } } } - -class MyGoogleAuth extends OAuth2Client { - constructor(client_id: string, client_secret: string, redirect_uri: string) { - super(client_id, client_secret, redirect_uri); - } - - isTokenExpiring() { - return super.isTokenExpiring(); - } - - async refreshToken(token: string | null | undefined) { - return super.refreshToken(token); - } -} diff --git a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts new file mode 100644 index 00000000000000..d84d6f80d350dd --- /dev/null +++ b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +const featuresRepositoryModuleMock = { + FeaturesRepository: vi.fn().mockImplementation(() => ({ + checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + })), +}; + +vi.mock("@calcom/features/flags/features.repository", () => featuresRepositoryModuleMock); diff --git a/packages/app-store/googlecalendar/lib/__mocks__/getGoogleAppKeys.ts b/packages/app-store/googlecalendar/lib/__mocks__/getGoogleAppKeys.ts new file mode 100644 index 00000000000000..3645aa255df9b9 --- /dev/null +++ b/packages/app-store/googlecalendar/lib/__mocks__/getGoogleAppKeys.ts @@ -0,0 +1,11 @@ +import { vi } from "vitest"; + +export const getGoogleAppKeysModuleMock = { + getGoogleAppKeys: vi.fn().mockResolvedValue({ + client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com", + client_secret: "xxxxxxxxxxxxxxxxxx", + redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"], + }), +}; + +vi.mock("../getGoogleAppKeys", () => getGoogleAppKeysModuleMock); diff --git a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts index 7c06b73959560f..0cb797e55028cb 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts @@ -36,5 +36,92 @@ const adminMock = { Admin: vi.fn(), }, }; +export interface MockJWT { + type: "jwt"; + config: { + email: string; + key: string; + scopes: string[]; + subject: string; + }; + authorize: () => Promise; +} -export { calendarMock, adminMock, setCredentialsMock, freebusyQueryMock, calendarListMock }; +export type MockOAuth2Client = { + type: "oauth2"; + args: [string, string, string]; + setCredentials: typeof setCredentialsMock; + refreshToken: Function; + isTokenExpiring: Function; +}; + +export const MOCK_JWT_TOKEN = { + access_token: "MOCK_ACCESS_TOKEN_JWT", + refresh_token: "placeholder_refresh_token", + scope: "https://www.googleapis.com/auth/calendar", + token_type: "Bearer", + expiry_date: 1625097600000, +}; + +export const MOCK_OAUTH2_TOKEN = { + access_token: "MOCK_ACCESS_TOKEN_OAUTH2", + refresh_token: "MOCK_REFRESH_TOKEN_OAUTH2", + scope: "https://www.googleapis.com/auth/calendar", + token_type: "Bearer", + expiry_date: 1625097600000, +}; + +let lastCreatedJWT: MockJWT | null = null; +let lastCreatedOAuth2Client: MockOAuth2Client | null = null; + +vi.mock("googleapis-common", async () => { + const actual = await vi.importActual("googleapis-common"); + return { + ...actual, + OAuth2Client: vi.fn().mockImplementation((...args: [string, string, string]) => { + lastCreatedOAuth2Client = { + type: "oauth2", + args, + setCredentials: setCredentialsMock, + isTokenExpiring: vi.fn().mockReturnValue(true), + refreshToken: vi.fn().mockResolvedValue({ + res: { + data: MOCK_OAUTH2_TOKEN, + status: 200, + statusText: "OK", + }, + }), + }; + return lastCreatedOAuth2Client; + }), + JWT: vi.fn().mockImplementation((config: MockJWT["config"]) => { + lastCreatedJWT = { + type: "jwt", + config, + authorize: vi.fn().mockResolvedValue(undefined), + }; + return lastCreatedJWT; + }), + }; +}); +vi.mock("@googleapis/admin", () => adminMock); +vi.mock("@googleapis/calendar", () => calendarMock); +const getLastCreatedJWT = () => lastCreatedJWT; +const getLastCreatedOAuth2Client = () => lastCreatedOAuth2Client; +const setLastCreatedJWT = (jwt: MockJWT | null) => { + lastCreatedJWT = jwt; +}; +const setLastCreatedOAuth2Client = (oauth2Client: MockOAuth2Client | null) => { + lastCreatedOAuth2Client = oauth2Client; +}; +export { + calendarMock, + adminMock, + setCredentialsMock, + freebusyQueryMock, + calendarListMock, + getLastCreatedJWT, + getLastCreatedOAuth2Client, + setLastCreatedJWT, + setLastCreatedOAuth2Client, +}; diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.auth.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.auth.test.ts new file mode 100644 index 00000000000000..31af2e8eed246e --- /dev/null +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.auth.test.ts @@ -0,0 +1,312 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; +import "../__mocks__/features.repository"; +import "../__mocks__/getGoogleAppKeys"; +import { + setCredentialsMock, + calendarListMock, + getLastCreatedJWT, + getLastCreatedOAuth2Client, + setLastCreatedJWT, + setLastCreatedOAuth2Client, + calendarMock, + adminMock, + MOCK_JWT_TOKEN, + MOCK_OAUTH2_TOKEN, +} from "../__mocks__/googleapis"; + +import { expect, test, beforeEach, vi, describe } from "vitest"; +import "vitest-fetch-mock"; + +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; + +import CalendarService from "../CalendarService"; +import { + createMockJWTInstance, + createInMemoryDelegationCredentialForCalendarService, + defaultDelegatedCredential, + createCredentialForCalendarService, +} from "./utils"; + +function expectJWTInstanceToBeCreated() { + expect(getLastCreatedJWT()).toBeDefined(); + expect(setCredentialsMock).not.toHaveBeenCalled(); +} + +function expectOAuth2InstanceToBeCreated() { + expect(setCredentialsMock).toHaveBeenCalled(); + expect(getLastCreatedJWT()).toBeNull(); +} + +function mockSuccessfulCalendarListFetch() { + calendarListMock.mockImplementation(() => { + return { + data: { items: [] }, + }; + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + setCredentialsMock.mockClear(); + calendarMock.calendar_v3.Calendar.mockClear(); + adminMock.admin_directory_v1.Admin.mockClear(); + + setLastCreatedJWT(null); + setLastCreatedOAuth2Client(null); + createMockJWTInstance({}); +}); + +async function expectNoCredentialsInDb() { + const credentials = await prismock.credential.findMany({}); + expect(credentials).toHaveLength(0); +} + +async function expectCredentialsInDb(credentials: CredentialForCalendarServiceWithEmail[]) { + const credentialsInDb = await prismock.credential.findMany({}); + expect(credentialsInDb.length).toBe(credentials.length); + expect(credentialsInDb).toEqual(expect.arrayContaining(credentials)); +} + +describe("GoogleCalendarService credential handling", () => { + describe("Delegation Credential", () => { + test("uses JWT auth with impersonation when listCalendars is called and creates a new credential in DB with the key as JWT token if it doesn't exist", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + mockSuccessfulCalendarListFetch(); + expectNoCredentialsInDb(); + const calendarService = new CalendarService(credentialWithDelegation); + await calendarService.listCalendars(); + expectJWTInstanceToBeCreated(); + await expectCredentialsInDb([ + expect.objectContaining({ + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + key: MOCK_JWT_TOKEN, + userId: credentialWithDelegation.userId, + }), + ]); + }); + + test("uses JWT auth with impersonation when listCalendars is called and updates the credential in DB with the new JWT token if it exists", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + mockSuccessfulCalendarListFetch(); + const existingCredential = await prismock.credential.create({ + data: { + type: "google_calendar", + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + userId: credentialWithDelegation.userId, + key: { + access_token: "CURRENT_ACCESS_TOKEN", + }, + }, + }); + const calendarService = new CalendarService(credentialWithDelegation); + await calendarService.listCalendars(); + expectJWTInstanceToBeCreated(); + await expectCredentialsInDb([ + expect.objectContaining({ + id: existingCredential.id, + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + key: MOCK_JWT_TOKEN, + userId: credentialWithDelegation.userId, + }), + ]); + }); + + test("JWT token is reused when not expired when listCalendars is called again on a new instance of CalendarService", async () => { + const jwtTokenThatHasNotExpired = { + ...MOCK_JWT_TOKEN, + expiry_date: Date.now() + 1000 * 60 * 60 * 24, + }; + createMockJWTInstance({ + tokenExpiryDate: jwtTokenThatHasNotExpired.expiry_date, + }); + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + console.log("TESTS: credentialWithDelegation", credentialWithDelegation); + + mockSuccessfulCalendarListFetch(); + await expectNoCredentialsInDb(); + console.log("TESTS: First instance of CalendarService"); + const calendarService1 = new CalendarService({ + ...credentialWithDelegation, + }); + await calendarService1.listCalendars(); + await expectCredentialsInDb([ + expect.objectContaining({ + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + key: jwtTokenThatHasNotExpired, + userId: credentialWithDelegation.userId, + }), + ]); + + const existingCredential = await prismock.credential.findFirst({ + where: { + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + userId: credentialWithDelegation.userId, + }, + }); + expect(existingCredential).toBeDefined(); + console.log("TESTS: Second instance of CalendarService"); + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "I_WOULD_ERROR_IF_YOU_USE_ME", + }, + }, + }, + }); + const calendarService2 = new CalendarService({ + ...credentialWithDelegation, + }); + await calendarService2.listCalendars(); + await expectCredentialsInDb([ + expect.objectContaining({ + // Same credential should be reused + id: existingCredential?.id, + delegationCredentialId: credentialWithDelegation.delegationCredentialId, + key: jwtTokenThatHasNotExpired, + }), + ]); + }); + }); + + describe("Non-Delegation Credential", () => { + test("uses OAuth2 auth when listCalendars is called", async () => { + const regularCredential = await createCredentialForCalendarService(); + mockSuccessfulCalendarListFetch(); + const calendarService = new CalendarService(regularCredential); + await calendarService.listCalendars(); + + expectOAuth2InstanceToBeCreated(); + + expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ + auth: getLastCreatedOAuth2Client(), + }); + await expectCredentialsInDb([ + expect.objectContaining({ + id: regularCredential.id, + key: MOCK_OAUTH2_TOKEN, + }), + ]); + }); + }); + + describe("Delegation Credential Error handling", () => { + test("handles clientId not added to Google Workspace Admin Console error", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "unauthorized_client", + }, + }, + }, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" + ); + }); + + test("handles DelegationCredential authorization errors appropriately", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "unauthorized_client", + }, + }, + }, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" + ); + }); + + test("handles invalid_grant error (user not in workspace) appropriately", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "invalid_grant", + }, + }, + }, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + `User ${credentialWithDelegation.user?.email} might not exist in Google Workspace` + ); + }); + + test("handles DelegationCredential authorization errors appropriately", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + createMockJWTInstance({ + authorizeError: new Error("Some unexpected error"), + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Error authorizing delegation credential" + ); + }); + + test("On missing user email for DelegationCredential, it should fallback to OAuth2 auth", async () => { + const credentialWithDelegation = await createInMemoryDelegationCredentialForCalendarService({ + user: { email: null }, + delegatedTo: defaultDelegatedCredential, + delegationCredentialId: "delegation-credential-id-1", + }); + + const calendarService = new CalendarService(credentialWithDelegation); + mockSuccessfulCalendarListFetch(); + await calendarService.listCalendars(); + expectOAuth2InstanceToBeCreated(); + }); + }); +}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts similarity index 62% rename from packages/app-store/googlecalendar/lib/CalendarService.test.ts rename to packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index d1f555fbaf4e03..e49bdcb72f5ea3 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -1,17 +1,20 @@ -import prismock from "../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../tests/libs/__mocks__/prisma"; import oAuthManagerMock, { defaultMockOAuthManager, setFullMockOAuthManagerRequest, -} from "../../tests/__mocks__/OAuthManager"; +} from "../../../tests/__mocks__/OAuthManager"; +import "../__mocks__/features.repository"; +import "../__mocks__/getGoogleAppKeys"; import { - adminMock, calendarMock, + adminMock, + setLastCreatedJWT, setCredentialsMock, + setLastCreatedOAuth2Client, freebusyQueryMock, calendarListMock, -} from "./__mocks__/googleapis"; +} from "../__mocks__/googleapis"; -import { JWT } from "googleapis-common"; import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; @@ -20,82 +23,17 @@ import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/date import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; -import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; -import CalendarService from "./CalendarService"; -import { getGoogleAppKeys } from "./getGoogleAppKeys"; +import CalendarService from "../CalendarService"; +import { + createMockJWTInstance, + createInMemoryDelegationCredentialForCalendarService, + defaultDelegatedCredential, + createCredentialForCalendarService, + createInMemoryCredential, +} from "./utils"; const log = logger.getSubLogger({ prefix: ["CalendarService.test"] }); -vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); - -interface MockJWT { - type: "jwt"; - config: { - email: string; - key: string; - scopes: string[]; - subject: string; - }; - authorize: () => Promise; -} - -interface MockOAuth2Client { - type: "oauth2"; - args: [string, string, string]; - setCredentials: typeof setCredentialsMock; -} - -let lastCreatedJWT: MockJWT | null = null; -let lastCreatedOAuth2Client: MockOAuth2Client | null = null; - -vi.mock("@calcom/features/flags/features.repository", () => ({ - FeaturesRepository: vi.fn().mockImplementation(() => ({ - checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), - })), -})); - -vi.mock("./getGoogleAppKeys", () => ({ - getGoogleAppKeys: vi.fn().mockResolvedValue({ - client_id: "xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com", - client_secret: "xxxxxxxxxxxxxxxxxx", - redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"], - }), -})); - -vi.mock("googleapis-common", async () => { - const actual = await vi.importActual("googleapis-common"); - return { - ...actual, - OAuth2Client: vi.fn().mockImplementation((...args: [string, string, string]) => { - lastCreatedOAuth2Client = { - type: "oauth2", - args, - setCredentials: setCredentialsMock, - }; - return lastCreatedOAuth2Client; - }), - JWT: vi.fn().mockImplementation((config: MockJWT["config"]) => { - lastCreatedJWT = { - type: "jwt", - config, - authorize: vi.fn().mockResolvedValue(undefined), - }; - return lastCreatedJWT; - }), - }; -}); -vi.mock("@googleapis/admin", () => adminMock); -vi.mock("@googleapis/calendar", () => calendarMock); - -async function expectCacheToBeNotSet({ credentialId }: { credentialId: number }) { - const caches = await prismock.calendarCache.findMany({ - where: { - credentialId, - }, - }); - - expect(caches).toHaveLength(0); -} async function expectCacheToBeSet({ credentialId, @@ -117,98 +55,6 @@ async function expectCacheToBeSet({ ); } -function createInMemoryCredential({ - userId, - delegationCredentialId, - delegatedTo, -}: { - userId: number; - delegationCredentialId: string | null; - delegatedTo: NonNullable; -}) { - return { - id: -1, - userId, - key: { - access_token: "NOOP_UNUSED_DELEGATION_TOKEN", - }, - invalid: false, - teamId: null, - team: null, - type: "google_calendar", - appId: "google-calendar", - delegatedToId: delegationCredentialId, - delegatedTo: delegatedTo.serviceAccountKey - ? { - serviceAccountKey: delegatedTo.serviceAccountKey, - } - : null, - }; -} - -async function createCredentialForCalendarService({ - user = undefined, - delegatedTo = null, - delegationCredentialId = null, -}: { - user?: { email: string | null }; - delegatedTo?: NonNullable | null; - delegationCredentialId?: string | null; -} = {}): Promise { - const defaultUser = await prismock.user.create({ - data: { - email: user?.email ?? "", - }, - }); - - const app = await prismock.app.create({ - data: { - slug: "google-calendar", - dirName: "google-calendar", - }, - }); - - const credential = { - ...getSampleCredential(), - ...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}), - key: { - ...googleTestCredentialKey, - expiry_date: Date.now() - 1000, - }, - }; - - const credentialInDb = !delegatedTo - ? await prismock.credential.create({ - data: { - ...credential, - user: { - connect: { - id: defaultUser.id, - }, - }, - app: { - connect: { - slug: app.slug, - }, - }, - }, - include: { - user: true, - }, - }) - : createInMemoryCredential({ - userId: defaultUser.id, - delegationCredentialId, - delegatedTo, - }); - - return { - ...credentialInDb, - user: user ? { email: user.email ?? "" } : null, - delegatedTo, - } as CredentialForCalendarServiceWithEmail; -} - async function createSelectedCalendarForDelegationCredential(data: { userId: number; credentialId: number | null; @@ -231,61 +77,6 @@ async function createSelectedCalendarForDelegationCredential(data: { }); } -async function createSelectedCalendarForRegularCredential(data: { - userId: number; - delegationCredentialId: null; - credentialId: number; - externalId: string; - integration: string; - googleChannelId: string | null; - googleChannelKind: string | null; - googleChannelResourceId: string | null; - googleChannelResourceUri: string | null; - googleChannelExpiration: string | null; -}) { - if (!data.credentialId) { - throw new Error("credentialId is required"); - } - - if (data.credentialId < 0) { - throw new Error("credentialId cannot be negative"); - } - - return await prismock.selectedCalendar.create({ - data: { - ...data, - delegationCredentialId: null, - credentialId: data.credentialId, - }, - }); -} - -const defaultDelegatedCredential = { - serviceAccountKey: { - client_email: "service@example.com", - client_id: "service-client-id", - private_key: "service-private-key", - }, -} as const; - -async function createDelegationCredentialForCalendarService({ - user, - delegatedTo, - delegationCredentialId, -}: { - user?: { email: string } | null; - delegatedTo?: typeof defaultDelegatedCredential; - delegationCredentialId: string; -}) { - return await createCredentialForCalendarService({ - user: user || { - email: "service@example.com", - }, - delegatedTo: delegatedTo || defaultDelegatedCredential, - delegationCredentialId, - }); -} - /** * The flow that sets CalendarCache must use CredentialForCalendarCache */ @@ -316,67 +107,6 @@ async function createDelegationCredentialForCalendarCache({ }; } -const createMockJWTInstance = ({ - email = "user@example.com", - authorizeError, -}: { - email?: string; - authorizeError?: { response?: { data?: { error?: string } } } | Error; -}) => { - const mockJWTInstance = { - type: "jwt" as const, - config: { - email: defaultDelegatedCredential.serviceAccountKey.client_email, - key: defaultDelegatedCredential.serviceAccountKey.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], - subject: email, - }, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - authorize: authorizeError ? vi.fn().mockRejectedValue(authorizeError) : vi.fn().mockResolvedValue(), - createScoped: vi.fn(), - getRequestMetadataAsync: vi.fn(), - fetchIdToken: vi.fn(), - hasUserScopes: vi.fn(), - getAccessToken: vi.fn(), - getRefreshToken: vi.fn(), - getTokenInfo: vi.fn(), - refreshAccessToken: vi.fn(), - revokeCredentials: vi.fn(), - revokeToken: vi.fn(), - verifyIdToken: vi.fn(), - on: vi.fn(), - setCredentials: vi.fn(), - getCredentials: vi.fn(), - hasAnyScopes: vi.fn(), - authorizeAsync: vi.fn(), - refreshTokenNoCache: vi.fn(), - createGToken: vi.fn(), - }; - - vi.mocked(JWT).mockImplementation(() => { - lastCreatedJWT = mockJWTInstance; - return mockJWTInstance as unknown as JWT; - }); - return mockJWTInstance; -}; - -const googleTestCredentialKey = { - scope: "https://www.googleapis.com/auth/calendar.events", - token_type: "Bearer", - expiry_date: 1625097600000, - access_token: "", - refresh_token: "", -}; - -const getSampleCredential = () => { - return { - invalid: false, - key: googleTestCredentialKey, - type: "google_calendar", - }; -}; - const testSelectedCalendar = { userId: 1, integration: "google_calendar", @@ -522,8 +252,8 @@ beforeEach(() => { calendarMock.calendar_v3.Calendar.mockClear(); adminMock.admin_directory_v1.Admin.mockClear(); - lastCreatedJWT = null; - lastCreatedOAuth2Client = null; + setLastCreatedJWT(null); + setLastCreatedOAuth2Client(null); createMockJWTInstance({}); }); @@ -802,7 +532,7 @@ describe("Watching and unwatching calendar", () => { describe("Delegation Credential", () => { test("On watching a SelectedCalendar having delegationCredential, it should set googleChannelId and other props", async () => { - const delegationCredential1Member1 = await createDelegationCredentialForCalendarService({ + const delegationCredential1Member1 = await createInMemoryDelegationCredentialForCalendarService({ user: { email: "user1@example.com" }, delegationCredentialId: "delegation-credential-id-1", }); @@ -1086,298 +816,6 @@ describe("Watching and unwatching calendar", () => { }); }); -test("`updateTokenObject` should update credential in DB as well as myGoogleAuth", async () => { - const credentialInDb = await createCredentialForCalendarService(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let updateTokenObject: any; - oAuthManagerMock.OAuthManager = vi.fn().mockImplementation((arg) => { - updateTokenObject = arg.updateTokenObject; - return { - getTokenObjectOrFetch: vi.fn().mockImplementation(() => { - return { - token: { - access_token: "FAKE_ACCESS_TOKEN", - }, - }; - }), - request: vi.fn().mockResolvedValue({ - json: [], - }), - }; - }); - - const calendarService = new CalendarService(credentialInDb); - await calendarService.listCalendars(); - - const newTokenObject = { - access_token: "NEW_FAKE_ACCESS_TOKEN", - }; - - // Scenario: OAuthManager causes `updateTokenObject` to be called - await updateTokenObject(newTokenObject); - - const newCredential = await prismock.credential.findFirst({ - where: { - id: credentialInDb.id, - }, - }); - - // Expect update in DB - expect(newCredential).toEqual( - expect.objectContaining({ - key: newTokenObject, - }) - ); - - // Expect update in myGoogleAuth credentials - expect(setCredentialsMock).toHaveBeenCalledWith(newTokenObject); -}); - -describe("Delegation Credential Error handling", () => { - test("handles clientId not added to Google Workspace Admin Console error", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: { - response: { - data: { - error: "unauthorized_client", - }, - }, - }, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow( - "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" - ); - }); - - test("handles DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: { - response: { - data: { - error: "unauthorized_client", - }, - }, - }, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow( - "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" - ); - }); - - test("handles invalid_grant error (user not in workspace) appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: { - response: { - data: { - error: "invalid_grant", - }, - }, - }, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow( - `User ${credentialWithDelegation.user?.email} might not exist in Google Workspace` - ); - }); - - test("handles general DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: new Error("Some unexpected error"), - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow("Error authorizing delegation credential"); - }); - - test("handles missing user email for DelegationCredential appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: null }, - delegatedTo: defaultDelegatedCredential, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); - - await calendarService.listCalendars(); - - expect(lastCreatedJWT).toBeNull(); - - const expectedOAuth2Client: MockOAuth2Client = { - type: "oauth2", - args: [client_id, client_secret, redirect_uris[0]], - setCredentials: setCredentialsMock, - }; - - expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); - }); -}); - -describe("GoogleCalendarService credential handling", () => { - test("uses JWT auth with impersonation when Delegation credential is provided", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - await calendarService.listCalendars(); - - const expectedJWTConfig: MockJWT = { - type: "jwt", - config: { - email: defaultDelegatedCredential.serviceAccountKey.client_email, - key: defaultDelegatedCredential.serviceAccountKey.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], - subject: "user@example.com", - }, - authorize: expect.any(Function) as () => Promise, - }; - - expect(lastCreatedJWT).toEqual(expect.objectContaining(expectedJWTConfig)); - - expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ - auth: lastCreatedJWT, - }); - }); - - test("uses OAuth2 auth when no Delegation credential is provided", async () => { - const regularCredential = await createCredentialForCalendarService(); - const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); - - const calendarService = new CalendarService(regularCredential); - await calendarService.listCalendars(); - - expect(lastCreatedJWT).toBeNull(); - - const expectedOAuth2Client: MockOAuth2Client = { - type: "oauth2", - args: [client_id, client_secret, redirect_uris[0]], - setCredentials: setCredentialsMock, - }; - - expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); - - expect(setCredentialsMock).toHaveBeenCalledWith(regularCredential.key); - - expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ - auth: lastCreatedOAuth2Client, - }); - }); - - test("handles DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: { - response: { - data: { - error: "unauthorized_client", - }, - }, - }, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow( - "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" - ); - }); - - test("handles invalid_grant error (user not in workspace) appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: { - response: { - data: { - error: "invalid_grant", - }, - }, - }, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow( - `User ${credentialWithDelegation.user?.email} might not exist in Google Workspace` - ); - }); - - test("handles general DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: "user@example.com" }, - delegatedTo: defaultDelegatedCredential, - }); - - createMockJWTInstance({ - authorizeError: new Error("Some unexpected error"), - }); - - const calendarService = new CalendarService(credentialWithDelegation); - - await expect(calendarService.listCalendars()).rejects.toThrow("Error authorizing delegation credential"); - }); - - test("handles missing user email for DelegationCredential appropriately", async () => { - const credentialWithDelegation = await createCredentialForCalendarService({ - user: { email: null }, - delegatedTo: defaultDelegatedCredential, - }); - - const calendarService = new CalendarService(credentialWithDelegation); - const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); - - await calendarService.listCalendars(); - - expect(lastCreatedJWT).toBeNull(); - - const expectedOAuth2Client: MockOAuth2Client = { - type: "oauth2", - args: [client_id, client_secret, redirect_uris[0]], - setCredentials: setCredentialsMock, - }; - - expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); - }); -}); - describe("getAvailability", () => { test("returns availability for selected calendars", async () => { const credential = await createCredentialForCalendarService(); diff --git a/packages/app-store/googlecalendar/lib/__tests__/utils.ts b/packages/app-store/googlecalendar/lib/__tests__/utils.ts new file mode 100644 index 00000000000000..bb3570108a1379 --- /dev/null +++ b/packages/app-store/googlecalendar/lib/__tests__/utils.ts @@ -0,0 +1,201 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; +import { MOCK_JWT_TOKEN, setLastCreatedJWT } from "../__mocks__/googleapis"; + +import { JWT } from "googleapis-common"; +import { vi } from "vitest"; +import "vitest-fetch-mock"; + +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; + +vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); + +export function createInMemoryCredential({ + userId, + delegationCredentialId, + delegatedTo, +}: { + userId: number; + delegationCredentialId: string; + delegatedTo: NonNullable; +}) { + if (delegatedTo && !delegationCredentialId) { + throw new Error("Test: createInMemoryCredential: delegationCredentialId is required"); + } + return { + id: -1, + userId, + key: { + access_token: "NOOP_UNUSED_DELEGATION_TOKEN", + }, + invalid: false, + teamId: null, + team: null, + type: "google_calendar", + appId: "google-calendar", + delegatedToId: delegationCredentialId, + delegatedTo: delegatedTo.serviceAccountKey + ? { + serviceAccountKey: delegatedTo.serviceAccountKey, + } + : null, + }; +} + +export async function createCredentialForCalendarService({ + user = undefined, + delegatedTo = null, + delegationCredentialId = null, +}: { + user?: { email: string | null }; + delegatedTo?: NonNullable | null; + delegationCredentialId?: string | null; +} = {}): Promise { + const defaultUser = await prismock.user.create({ + data: { + email: user?.email ?? "", + }, + }); + + const app = await prismock.app.create({ + data: { + slug: "google-calendar", + dirName: "google-calendar", + }, + }); + + const credential = { + ...getSampleCredential(), + ...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}), + key: { + ...googleTestCredentialKey, + expiry_date: Date.now() - 1000, + }, + }; + + const credentialInDbOrInMemory = !delegatedTo + ? await prismock.credential.create({ + data: { + ...credential, + user: { + connect: { + id: defaultUser.id, + }, + }, + app: { + connect: { + slug: app.slug, + }, + }, + }, + include: { + user: true, + }, + }) + : createInMemoryCredential({ + userId: defaultUser.id, + delegationCredentialId: delegationCredentialId!, + delegatedTo, + }); + + return { + ...credentialInDbOrInMemory, + delegationCredentialId: delegationCredentialId ?? null, + user: user ? { email: user.email ?? "" } : null, + delegatedTo, + } as CredentialForCalendarServiceWithEmail; +} + +export const defaultDelegatedCredential = { + serviceAccountKey: { + client_email: "service@example.com", + client_id: "service-client-id", + private_key: "service-private-key", + }, +} as const; + +export async function createInMemoryDelegationCredentialForCalendarService({ + user, + delegatedTo, + delegationCredentialId, +}: { + user?: { email: string | null } | null; + delegatedTo?: typeof defaultDelegatedCredential; + delegationCredentialId: string; +}) { + return await createCredentialForCalendarService({ + user: user || { + email: "service@example.com", + }, + delegatedTo: delegatedTo || defaultDelegatedCredential, + delegationCredentialId, + }); +} + +export const createMockJWTInstance = ({ + email = "user@example.com", + authorizeError, + tokenExpiryDate, +}: { + email?: string; + authorizeError?: { response?: { data?: { error?: string } } } | Error; + tokenExpiryDate?: number; +}) => { + console.log("createMockJWTInstance", { email, authorizeError }); + const mockJWTInstance = { + type: "jwt" as const, + config: { + email: defaultDelegatedCredential.serviceAccountKey.client_email, + key: defaultDelegatedCredential.serviceAccountKey.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: email, + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + authorize: authorizeError + ? vi.fn().mockRejectedValue(authorizeError) + : vi.fn().mockResolvedValue({ + ...MOCK_JWT_TOKEN, + expiry_date: tokenExpiryDate ?? MOCK_JWT_TOKEN.expiry_date, + }), + createScoped: vi.fn(), + getRequestMetadataAsync: vi.fn(), + fetchIdToken: vi.fn(), + hasUserScopes: vi.fn(), + getAccessToken: vi.fn(), + getRefreshToken: vi.fn(), + getTokenInfo: vi.fn(), + refreshAccessToken: vi.fn(), + revokeCredentials: vi.fn(), + revokeToken: vi.fn(), + verifyIdToken: vi.fn(), + on: vi.fn(), + setCredentials: vi.fn(), + getCredentials: vi.fn(), + hasAnyScopes: vi.fn(), + authorizeAsync: vi.fn(), + refreshTokenNoCache: vi.fn(), + createGToken: vi.fn(), + }; + + vi.mocked(JWT).mockImplementation(() => { + setLastCreatedJWT(mockJWTInstance); + return mockJWTInstance as unknown as JWT; + }); + return mockJWTInstance; +}; + +const googleTestCredentialKey = { + scope: "https://www.googleapis.com/auth/calendar.events", + token_type: "Bearer", + expiry_date: 1625097600000, + access_token: "", + refresh_token: "", +}; + +const getSampleCredential = () => { + return { + invalid: false, + key: googleTestCredentialKey, + type: "google_calendar", + }; +}; diff --git a/packages/lib/delegationCredential/server.ts b/packages/lib/delegationCredential/server.ts index d051cc929f673c..b766c6a5afbc4e 100644 --- a/packages/lib/delegationCredential/server.ts +++ b/packages/lib/delegationCredential/server.ts @@ -450,7 +450,17 @@ export const enrichHostsWithDelegationCredentials = async < }, }; }); - log.debug("enrichHostsWithDelegationCredentials", safeStringify({ enrichedHosts, orgId })); + log.debug( + "enrichHostsWithDelegationCredentials", + safeStringify({ + enrichedHosts: enrichedHosts.map((host) => { + return { + userId: host.user.id, + }; + }), + orgId, + }) + ); return enrichedHosts; }; diff --git a/packages/lib/piiFreeData.ts b/packages/lib/piiFreeData.ts index 01aa8088865cf6..7b887203804717 100644 --- a/packages/lib/piiFreeData.ts +++ b/packages/lib/piiFreeData.ts @@ -58,13 +58,17 @@ export function getPiiFreeBooking(booking: { }; } -export function getPiiFreeCredential(credential: Partial) { +export function getPiiFreeCredential(credential: Partial & { delegatedTo?: unknown }) { /** * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ const booleanKeyStatus = getBooleanStatus(credential?.key); - return { ...credential, key: booleanKeyStatus }; + return { + ...credential, + key: booleanKeyStatus, + delegatedTo: !!credential.delegatedTo, + }; } export function getPiiFreeSelectedCalendar(selectedCalendar: Partial) { diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index fe5f82cb8b72af..18b3504c08251b 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -1,9 +1,14 @@ +import type { Prisma } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { buildNonDelegationCredential } from "../../delegationCredential/server"; +const log = logger.getSubLogger({ prefix: ["CredentialRepository"] }); + type CredentialCreateInput = { type: string; key: any; @@ -157,4 +162,68 @@ export class CredentialRepository { }; }); } + + static async findUniqueByUserIdAndDelegationCredentialId({ + userId, + delegationCredentialId, + }: { + userId: number; + delegationCredentialId: string; + }) { + const delegationUserCredentials = await prisma.credential.findMany({ + where: { + userId, + delegationCredentialId, + }, + }); + + if (delegationUserCredentials.length > 1) { + // Instead of crashing use the first one and log for observability + // TODO: Plan to add a unique constraint on userId and delegationCredentialId + log.error(`DelegationCredential: Multiple delegation user credentials found - this should not happen`, { + userId, + delegationCredentialId, + }); + } + + return delegationUserCredentials[0]; + } + + static async updateWhereUserIdAndDelegationCredentialId({ + userId, + delegationCredentialId, + data, + }: { + userId: number; + delegationCredentialId: string; + data: { + key: Prisma.InputJsonValue; + }; + }) { + return prisma.credential.updateMany({ + where: { + userId, + delegationCredentialId, + }, + data, + }); + } + + static async createDelegationCredential({ + userId, + delegationCredentialId, + type, + key, + }: { + userId: number; + delegationCredentialId: string; + type: string; + key: Prisma.InputJsonValue; + }) { + return prisma.credential.create({ data: { userId, delegationCredentialId, type, key } }); + } + + static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) { + return prisma.credential.update({ where: { id }, data }); + } } diff --git a/packages/prisma/migrations/20250525034030_add_index_credential_delegation_credential_id/migration.sql b/packages/prisma/migrations/20250525034030_add_index_credential_delegation_credential_id/migration.sql new file mode 100644 index 00000000000000..ed2958f0dd6d94 --- /dev/null +++ b/packages/prisma/migrations/20250525034030_add_index_credential_delegation_credential_id/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "Credential_userId_idx"; + +-- CreateIndex +CREATE INDEX "Credential_userId_delegationCredentialId_idx" ON "Credential"("userId", "delegationCredentialId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e017f97a98f267..ed288dbc951846 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -221,10 +221,10 @@ model Credential { delegationCredentialId String? delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) - @@index([userId]) @@index([appId]) @@index([subscriptionId]) @@index([invalid]) + @@index([userId, delegationCredentialId]) } enum IdentityProvider { diff --git a/scripts/prepare-local-for-delegation-credentials-testing.js b/scripts/prepare-local-for-delegation-credentials-testing.js new file mode 100644 index 00000000000000..8f9ee504f71efa --- /dev/null +++ b/scripts/prepare-local-for-delegation-credentials-testing.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * This script is used to prepare local environment for delegation credentials testing. + * It prepares Acme organization and its owner user with email owner1-acme@example.com to test Delegation Credentials with Calendar Cache + */ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +async function main() { + // Parse newEmail from args + const newEmail = process.argv[2] || "hariom@cal.com"; + console.log(`Using newEmail: ${newEmail}`); + + // 1. Update user email + let user = await prisma.user.findUnique({ + where: { email: "owner1-acme@example.com" }, + }); + if (!user) { + // Check if user with newEmail exists + user = await prisma.user.findUnique({ where: { email: newEmail } }); + if (user) { + console.log(`User with newEmail (${newEmail}) already exists. Skipping email update step.`); + } else { + console.error( + "User with email owner1-acme@example.com not found, and user with newEmail also not found." + ); + process.exit(1); + } + } else { + if (user.email !== newEmail) { + await prisma.user.update({ + where: { id: user.id }, + data: { email: newEmail }, + }); + console.log(`Updated user email to ${newEmail}`); + } else { + console.log("User email already set to newEmail, skipping update."); + } + } + + // 2. Find organization (Team) + const org = await prisma.team.findFirst({ + where: { slug: "acme", isOrganization: true }, + }); + if (!org) { + console.error("Organization (Team) with slug=acme and isOrganization=true not found."); + process.exit(1); + } + console.log(`Found organization: id=${org.id}, slug=${org.slug}`); + + // 3. Ensure TeamFeatures: delegation-credential + const delegationFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: org.id, + featureId: "delegation-credential", + }, + }, + }); + if (!delegationFeature) { + await prisma.teamFeatures.create({ + data: { + teamId: org.id, + featureId: "delegation-credential", + assignedAt: new Date(), + assignedBy: "prepare-local-script", + }, + }); + console.log("Created TeamFeatures: delegation-credential"); + } else { + console.log("TeamFeatures: delegation-credential already exists, skipping."); + } + + // 4. Ensure TeamFeatures: calendar-cache + const calendarCacheFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: org.id, + featureId: "calendar-cache", + }, + }, + }); + if (!calendarCacheFeature) { + await prisma.teamFeatures.create({ + data: { + teamId: org.id, + featureId: "calendar-cache", + assignedAt: new Date(), + assignedBy: "prepare-local-script", + }, + }); + console.log("Created TeamFeatures: calendar-cache"); + } else { + console.log("TeamFeatures: calendar-cache already exists, skipping."); + } + + // 5. Add WorkspacePlatform record + const workspacePlatform = await prisma.workspacePlatform.findUnique({ + where: { slug: "google" }, + }); + if (!workspacePlatform) { + await prisma.workspacePlatform.create({ + data: { + slug: "google", + name: "Google", + enabled: true, + description: "Google Workspace Platform", + defaultServiceAccountKey: {}, // Empty object, update as needed + }, + }); + console.log("Created WorkspacePlatform: google"); + } else { + console.log("WorkspacePlatform: google already exists, skipping."); + } + + // 6. Enable Feature records for 'calendar-cache' and 'delegation-credential' + const featureSlugs = ["calendar-cache", "delegation-credential"]; + for (const slug of featureSlugs) { + const feature = await prisma.feature.findUnique({ where: { slug } }); + if (!feature) { + console.error(`Feature with slug ${slug} not found.`); + process.exit(1); + } + if (!feature.enabled) { + await prisma.feature.update({ where: { slug }, data: { enabled: true } }); + console.log(`Enabled Feature: ${slug}`); + } else { + console.log(`Feature: ${slug} already enabled, skipping.`); + } + } + console.log(`Now you can sign in with ${newEmail} and create a new Delegation Credential.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/tests/libs/__mocks__/app-store.ts b/tests/libs/__mocks__/app-store.ts index 88ee6a355ae82a..01da9339d5a88a 100644 --- a/tests/libs/__mocks__/app-store.ts +++ b/tests/libs/__mocks__/app-store.ts @@ -11,7 +11,9 @@ beforeEach(() => { const appStoreMock = mockDeep({ fallbackMockImplementation: () => { - throw new Error("Unimplemented appStoreMock. You seem to have mocked the app that you are trying to use"); + throw new Error( + "Unimplemented appStoreMock. You seem to have not mocked the app that you are trying to use" + ); }, }); export default appStoreMock; From ef45c63ee67fb180dd3b9c441238ccee4935138c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 26 May 2025 11:56:38 +0530 Subject: [PATCH 2/2] Add missing appId --- packages/app-store/_utils/oauth/updateTokenObject.ts | 4 +++- packages/app-store/googlecalendar/lib/CalendarAuth.ts | 1 + packages/lib/server/repository/credential.ts | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app-store/_utils/oauth/updateTokenObject.ts b/packages/app-store/_utils/oauth/updateTokenObject.ts index c5d8fb5a4170b5..3c3653f4685a65 100644 --- a/packages/app-store/_utils/oauth/updateTokenObject.ts +++ b/packages/app-store/_utils/oauth/updateTokenObject.ts @@ -41,6 +41,7 @@ export const updateTokenObjectInDb = async ( authStrategy: "jwt"; userId: number | null; credentialType: string; + appId: string; delegatedToId: string | null; } | { @@ -51,7 +52,7 @@ export const updateTokenObjectInDb = async ( ) => { const { tokenObject } = args; if (args.authStrategy === "jwt") { - const { userId, delegatedToId, credentialType } = args; + const { userId, delegatedToId, credentialType, appId } = args; if (!userId) { log.error("Cannot update token object in DB for Delegation as userId is not present"); return; @@ -77,6 +78,7 @@ export const updateTokenObjectInDb = async ( delegationCredentialId: delegatedToId, type: credentialType, key: tokenObject as Prisma.InputJsonValue, + appId, }); } } else { diff --git a/packages/app-store/googlecalendar/lib/CalendarAuth.ts b/packages/app-store/googlecalendar/lib/CalendarAuth.ts index e35a23698cf461..dc03ccd543f261 100644 --- a/packages/app-store/googlecalendar/lib/CalendarAuth.ts +++ b/packages/app-store/googlecalendar/lib/CalendarAuth.ts @@ -219,6 +219,7 @@ export class CalendarAuth { userId: this.credential.userId ?? null, delegatedToId: this.credential.delegatedToId ?? null, credentialType: this.credential.type, + appId: metadata.slug, }); if (this.oAuthClient) { this.oAuthClient.setCredentials(token); diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index 18b3504c08251b..6fbf39017ce43c 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -214,13 +214,15 @@ export class CredentialRepository { delegationCredentialId, type, key, + appId, }: { userId: number; delegationCredentialId: string; type: string; key: Prisma.InputJsonValue; + appId: string; }) { - return prisma.credential.create({ data: { userId, delegationCredentialId, type, key } }); + return prisma.credential.create({ data: { userId, delegationCredentialId, type, key, appId } }); } static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) {