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:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-581
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Credential_userId_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Credential_userId_delegationCredentialId_idx" ON "Credential"("userId", "delegationCredentialId");
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user