fix: GoogleCalendar - Ensure that Delegation Credentials are able to reuse access_token (#21389)

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Hariom BalharaandOmar López authored and GitHub committed 2025-05-26 15:19:52 +01:00
1 parent b8ffcef45e
commit 30060f03eb
21 files changed
+1457 -834

No files matched your search

@@ -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,
});
});
});
+73 -21
View File
@@ -58,20 +58,24 @@ type CredentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: boolean;
};
type CurrentTokenObject = z.infer<typeof OAuth2UniversalSchema>;
type GetCurrentTokenObject = () => Promise<CurrentTokenObject>;
/**
* 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<typeof OAuth2UniversalSchema>;
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<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
isTokenExpiring = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
// 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<typeof OAuth2UniversalSchema>;
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(
@@ -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<CredentialForCalendarService, "key" | "id" | "delegatedToId" | "userId">
) {
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;
}
@@ -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<CredentialPayload, "key" | "id">) {
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
if (!parsedTokenResponse.success) {
logger.error(
"GoogleCalendarService-getTokenObjectFromCredential",
@@ -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,
};
@@ -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,67 @@ 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<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>;
} & (
| {
authStrategy: "jwt";
userId: number | null;
credentialType: string;
appId: string;
delegatedToId: string | null;
}
| {
authStrategy: "oauth";
credentialId: number;
}
)
) => {
const { tokenObject } = args;
if (args.authStrategy === "jwt") {
const { userId, delegatedToId, credentialType, appId } = 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,
appId,
});
}
} else {
const { credentialId } = args;
await CredentialRepository.updateWhereId({
id: credentialId,
data: {
key: tokenObject as Prisma.InputJsonValue,
},
});
}
};
@@ -0,0 +1,282 @@
/* 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<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
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<CalendarAuth["initAuthMechanism"]>;
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,
appId: metadata.slug,
});
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<calendar_v3.Calendar> {
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,
});
}
}
@@ -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<typeof this.initGoogleAuth>;
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<calendar_v3.Calendar> {
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<calendar_v3.Schema$FreeBusyResponse> {
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);
}
}
@@ -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);
@@ -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);
@@ -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<void>;
}
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,
};
@@ -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();
});
});
});
@@ -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<void>;
}
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<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
}) {
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<CredentialForCalendarServiceWithEmail["delegatedTo"]> | null;
delegationCredentialId?: string | null;
} = {}): Promise<CredentialForCalendarServiceWithEmail> {
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<void>,
};
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();
@@ -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<CredentialForCalendarServiceWithEmail["delegatedTo"]>;
}) {
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<CredentialForCalendarServiceWithEmail["delegatedTo"]> | null;
delegationCredentialId?: string | null;
} = {}): Promise<CredentialForCalendarServiceWithEmail> {
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",
};
};
+11 -1
View File
@@ -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;
};
+6 -2
View File
@@ -58,13 +58,17 @@ export function getPiiFreeBooking(booking: {
};
}
export function getPiiFreeCredential(credential: Partial<Credential>) {
export function getPiiFreeCredential(credential: Partial<Credential> & { 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<SelectedCalendar>) {
@@ -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,70 @@ 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,
appId,
}: {
userId: number;
delegationCredentialId: string;
type: string;
key: Prisma.InputJsonValue;
appId: string;
}) {
return prisma.credential.create({ data: { userId, delegationCredentialId, type, key, appId } });
}
static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) {
return prisma.credential.update({ where: { id }, data });
}
}
@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "Credential_userId_idx";
-- CreateIndex
CREATE INDEX "Credential_userId_delegationCredentialId_idx" ON "Credential"("userId", "delegationCredentialId");
+1 -1
View File
@@ -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 {
@@ -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();
});
+3 -1
View File
@@ -11,7 +11,9 @@ beforeEach(() => {
const appStoreMock = mockDeep<typeof appStore>({
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;