2
0
Files
cal/calcom/packages/app-store/_utils/oauth/OAuthManager.ts
2024-08-09 00:39:27 +02:00

566 lines
22 KiB
TypeScript

/**
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
* It is aware of the credential sync endpoint and can sync the token from the third party source.
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
*
* For a recommended usage example, see Zoom VideoApiAdapter.ts
*/
import type { z } from "zod";
import { CREDENTIAL_SYNC_ENDPOINT } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { AxiosLikeResponseToFetchResponse } from "./AxiosLikeResponseToFetchResponse";
import type { OAuth2TokenResponseInDbWhenExistsSchema, OAuth2UniversalSchema } from "./universalSchema";
import { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
const log = logger.getSubLogger({ prefix: ["app-store/_utils/oauth/OAuthManager"] });
export const enum TokenStatus {
UNUSABLE_TOKEN_OBJECT,
UNUSABLE_ACCESS_TOKEN,
INCONCLUSIVE,
VALID,
}
type ResourceOwner =
| {
id: number | null;
type: "team";
}
| {
id: number | null;
type: "user";
};
type FetchNewTokenObject = ({ refreshToken }: { refreshToken: string | null }) => Promise<Response | null>;
type UpdateTokenObject = (
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) => Promise<void>;
type isTokenObjectUnusable = (response: Response) => Promise<{ reason: string } | null>;
type isAccessTokenUnusable = (response: Response) => Promise<{ reason: string } | null>;
type IsTokenExpired = (token: z.infer<typeof OAuth2UniversalSchema>) => Promise<boolean> | boolean;
type InvalidateTokenObject = () => Promise<void>;
type ExpireAccessToken = () => Promise<void>;
type CredentialSyncVariables = {
/**
* The secret required to access the credential sync endpoint
*/
CREDENTIAL_SYNC_SECRET: string | undefined;
/**
* The header name that the secret should be passed in
*/
CREDENTIAL_SYNC_SECRET_HEADER_NAME: string;
/**
* The endpoint where the credential sync should happen
*/
CREDENTIAL_SYNC_ENDPOINT: string | undefined;
APP_CREDENTIAL_SHARING_ENABLED: boolean;
};
/**
* 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>;
private resourceOwner: ResourceOwner;
private appSlug: string;
private fetchNewTokenObject: FetchNewTokenObject;
private updateTokenObject: UpdateTokenObject;
private isTokenObjectUnusable: isTokenObjectUnusable;
private isAccessTokenUnusable: isAccessTokenUnusable;
private isTokenExpired: IsTokenExpired;
private invalidateTokenObject: InvalidateTokenObject;
private expireAccessToken: ExpireAccessToken;
private credentialSyncVariables: CredentialSyncVariables;
private useCredentialSync: boolean;
private autoCheckTokenExpiryOnRequest: boolean;
constructor({
resourceOwner,
appSlug,
currentTokenObject,
fetchNewTokenObject,
updateTokenObject,
isTokenObjectUnusable,
isAccessTokenUnusable,
invalidateTokenObject,
expireAccessToken,
credentialSyncVariables,
autoCheckTokenExpiryOnRequest = true,
isTokenExpired = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
log.debug(
"isTokenExpired called",
safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() })
);
return getExpiryDate() <= Date.now();
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
}
function getExpiryDate() {
if (token.expiry_date) {
return token.expiry_date;
}
// It is usually in "seconds since now" but due to some integrations logic converting it to "seconds since epoch"(e.g. Office365Calendar has done that) we need to confirm what is the case here.
// But we for now know that it is in seconds for sure
// If it is not relative to epoch then it would be wrong to use it as it would make the token as non-expired when it could be expired
if (token.expires_in && isRelativeToEpoch(token.expires_in)) {
return token.expires_in * 1000;
}
// 0 means it would be expired as Date.now() is greater than that
return 0;
}
},
}: {
/**
* The resource owner for which the token is being managed
*/
resourceOwner: ResourceOwner;
/**
* Does response for any request contain information that refresh_token became invalid and thus the entire token object become unusable
* Note: Right now, the implementations of this function makes it so that the response is considered invalid(sometimes) even if just access_token is revoked or invalid. In that case, regenerating access token should work. So, we shouldn't mark the token as invalid in that case.
* We should instead mark the token as expired. We could do that by introducing isAccessTokenInvalid function
*
* @param response
* @returns
*/
isTokenObjectUnusable: isTokenObjectUnusable;
/**
*
*/
isAccessTokenUnusable: isAccessTokenUnusable;
/**
* The current token object.
*/
currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
/**
* 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.
* If credential syncing is still enabled `fetchNewTokenObject` wouldn't be called
*/
fetchNewTokenObject: FetchNewTokenObject;
/**
* update token object
*/
updateTokenObject: UpdateTokenObject;
/**
* Handler to invalidate the token object. It is called when the token object is invalid and credential syncing is disabled
*/
invalidateTokenObject: InvalidateTokenObject;
/*
* Handler to expire the access token. It is called when credential syncing is enabled and when the token object expires
*/
expireAccessToken: ExpireAccessToken;
/**
* The variables required for credential syncing
*/
credentialSyncVariables: CredentialSyncVariables;
/**
* If the token should be checked for expiry before sending a request
*/
autoCheckTokenExpiryOnRequest?: boolean;
/**
* If there is a different way to check if the token is expired(and not the standard way of checking expiry_date)
*/
isTokenExpired?: IsTokenExpired;
}) {
this.resourceOwner = resourceOwner;
this.currentTokenObject = currentTokenObject;
this.appSlug = appSlug;
this.fetchNewTokenObject = fetchNewTokenObject;
this.isTokenObjectUnusable = isTokenObjectUnusable;
this.isAccessTokenUnusable = isAccessTokenUnusable;
this.isTokenExpired = isTokenExpired;
this.invalidateTokenObject = invalidateTokenObject;
this.expireAccessToken = expireAccessToken;
this.credentialSyncVariables = credentialSyncVariables;
this.useCredentialSync = !!(
credentialSyncVariables.APP_CREDENTIAL_SHARING_ENABLED &&
credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME &&
credentialSyncVariables.CREDENTIAL_SYNC_SECRET
);
if (this.useCredentialSync) {
// Though it should be validated without credential sync as well but it seems like we have some credentials without userId in production
// So, we are not validating it for now
ensureValidResourceOwner(resourceOwner);
}
this.autoCheckTokenExpiryOnRequest = autoCheckTokenExpiryOnRequest;
this.updateTokenObject = updateTokenObject;
}
private isResponseNotOkay(response: Response) {
return !response.ok || response.status < 200 || response.status >= 300;
}
public async getTokenObjectOrFetch() {
const myLog = log.getSubLogger({
prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`],
});
const isExpired = await this.isTokenExpired(this.currentTokenObject);
myLog.debug(
"getTokenObjectOrFetch called",
safeStringify({
isExpired,
resourceOwner: this.resourceOwner,
})
);
if (!isExpired) {
myLog.debug("Token is not expired. Returning the current token object");
return { token: this.normalizeNewlyReceivedToken(this.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()),
};
myLog.debug("Token is expired. So, returning new token object");
this.currentTokenObject = token;
await this.updateTokenObject(token);
return { token, isUpdated: true };
}
}
public async request(arg: { url: string; options: RequestInit }): Promise<{
tokenStatus: TokenStatus;
json: unknown;
}>;
public async request<T>(
customFetch: () => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>
): Promise<{
tokenStatus: TokenStatus;
json: T;
}>;
/**
* Send request automatically adding the Authorization header with the access token. More importantly, handles token invalidation
*/
public async request<T>(
customFetchOrUrlAndOptions:
| { url: string; options: RequestInit }
| (() => Promise<
AxiosLikeResponseToFetchResponse<{
status: number;
statusText: string;
data: T;
}>
>)
) {
let response;
const myLog = log.getSubLogger({ prefix: ["request"] });
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
if (typeof customFetchOrUrlAndOptions === "function") {
myLog.debug("Sending request using customFetch");
const customFetch = customFetchOrUrlAndOptions;
try {
response = await customFetch();
} catch (e) {
// Get response from error so that code further down can categorize it into tokenUnusable or access token unusable
// Those methods accept response only
response = handleFetchError(e);
}
} else {
const { url, options } = customFetchOrUrlAndOptions;
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
myLog.debug("Sending request using fetch", safeStringify({ customFetchOrUrlAndOptions, headers }));
// We don't catch fetch error here because such an error would be temporary and we shouldn't take any action on it.
response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
}
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
const { tokenStatus, json } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
// In case of Credential Sync, we expire the token so that through the sync we can refresh the token
// TODO: We should consider sending a special 'reason' query param to toke sync endpoint to convey the reason for getting token
await this.invalidate();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
} else if (tokenStatus === TokenStatus.INCONCLUSIVE) {
await this.onInconclusiveResponse();
}
// We are done categorizing the token status. Now, we can throw back
if ("myFetchError" in (json || {})) {
throw new Error(json.myFetchError);
}
return { tokenStatus: tokenStatus, json };
}
/**
* 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.
*/
public async requestRaw({ url, options }: { url: string; options: RequestInit }) {
const myLog = log.getSubLogger({ prefix: ["requestRaw"] });
myLog.debug("Sending request using fetch", safeStringify({ url, options }));
if (this.autoCheckTokenExpiryOnRequest) {
await this.getTokenObjectOrFetch();
}
const headers = {
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
"Content-Type": "application/json",
...options?.headers,
};
const response = await fetch(url, {
method: "GET",
...options,
headers: headers,
});
myLog.debug(
"Response from request",
safeStringify({
text: await response.clone().text(),
status: response.status,
statusText: response.statusText,
})
);
if (this.isResponseNotOkay(response)) {
await this.onInconclusiveResponse();
}
return response;
}
private async onInconclusiveResponse() {
const myLog = log.getSubLogger({ prefix: ["onInconclusiveResponse"] });
myLog.debug("Expiring the access token");
// We can't really take any action on inconclusive response
// But in case of credential sync we should expire the token so that through the sync we can possibly fix the issue by refreshing the token
// It is important because in that cases tokens have an infinite expiry and it is possible that the token is revoked and isAccessUnusable and isTokenObjectUnusable couldn't detect the issue
if (this.useCredentialSync) {
await this.expireAccessToken();
}
}
private async invalidate() {
const myLog = log.getSubLogger({ prefix: ["invalidate"] });
if (this.useCredentialSync) {
myLog.debug("Expiring the access token");
// We are not calling it through refreshOAuthToken flow because the token is refreshed already there
// There is no point expiring the token as we will probably get the same result in that case.
await this.expireAccessToken();
} else {
myLog.debug("Invalidating the token object");
// In case credential sync is enabled there is no point of marking the token as invalid as user doesn't take action on that.
// The third party needs to sync the correct credential back which we get done by marking the token as expired.
await this.invalidateTokenObject();
}
}
private normalizeNewlyReceivedToken(
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
) {
if (!token.expiry_date && !token.expires_in) {
// Use a practically infinite expiry(a year) for when Credential Sync is enabled. Token is expected to be refreshed by the API request from the credential source.
// If credential sync is not enabled, we should consider the token as expired otherwise the token could be considered valid forever
token.expiry_date = this.useCredentialSync ? Date.now() + 365 * 24 * 3600 * 1000 : 0;
} else if (token.expires_in !== undefined && token.expiry_date === undefined) {
token.expiry_date = Math.round(Date.now() + token.expires_in * 1000);
// As expires_in could be relative to current time, we can't keep it in the token object as it could endup giving wrong absolute expiry_time if outdated value is used
// That could happen if we merge token objects which we do
delete token.expires_in;
}
return token;
}
// 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() {
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 ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME ||
!this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT
) {
throw new Error("Credential syncing is enabled but the required env variables are not set");
}
myLog.debug(
"Refreshing OAuth token from credential sync endpoint",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
endpoint: CREDENTIAL_SYNC_ENDPOINT,
})
);
try {
response = await fetch(`${this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT}`, {
method: "POST",
headers: {
[this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]:
this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET,
},
body: new URLSearchParams({
calcomUserId: this.resourceOwner.id.toString(),
appSlug: this.appSlug,
}),
});
} catch (e) {
myLog.error("Could not refresh the token.", safeStringify(e));
throw new Error(
`Could not refresh the token due to connection issue with the endpoint: ${CREDENTIAL_SYNC_ENDPOINT}`
);
}
} else {
myLog.debug(
"Refreshing OAuth token",
safeStringify({
appSlug: this.appSlug,
resourceOwner: this.resourceOwner,
})
);
try {
response = await this.fetchNewTokenObject({ refreshToken });
} catch (e) {
response = handleFetchError(e);
}
if (!response) {
throw new Error("`fetchNewTokenObject` could not refresh the token");
}
}
const clonedResponse = response.clone();
myLog.debug(
"Response from refreshOAuthToken",
safeStringify({
text: await clonedResponse.text(),
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
})
);
const { json, tokenStatus } = await this.getAndValidateOAuth2Response({
response,
});
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
await this.invalidateTokenObject();
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
await this.expireAccessToken();
}
const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json);
if (!parsedToken.success) {
myLog.error("Token parsing error:", safeStringify(parsedToken.error.issues));
throw new Error("Invalid token response");
}
return parsedToken.data;
}
private async getAndValidateOAuth2Response({ response }: { response: Response }) {
const myLog = log.getSubLogger({ prefix: ["getAndValidateOAuth2Response"] });
const clonedResponse = response.clone();
// handle empty response (causes crash otherwise on doing json() as "" is invalid JSON) which is valid in some cases like PATCH calls(with 204 response)
if ((await clonedResponse.text()).trim() === "") {
return { tokenStatus: TokenStatus.VALID, json: null, invalidReason: null } as const;
}
const tokenObjectUsabilityRes = await this.isTokenObjectUnusable(response.clone());
const accessTokenUsabilityRes = await this.isAccessTokenUnusable(response.clone());
const isNotOkay = this.isResponseNotOkay(response);
const json = await response.json();
if (tokenObjectUsabilityRes?.reason) {
myLog.error("Token Object has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
invalidReason: tokenObjectUsabilityRes.reason,
json,
} as const;
}
if (accessTokenUsabilityRes?.reason) {
myLog.error("Access Token has become unusable");
return {
tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN,
invalidReason: accessTokenUsabilityRes?.reason,
json,
};
}
// Any handlable not ok response should be handled through isTokenObjectUnusable or isAccessTokenUnusable but if still not handled, we should throw an error
// So, that the caller can handle it. It could be a network error or some other temporary error from the third party App itself.
if (isNotOkay) {
return {
tokenStatus: TokenStatus.INCONCLUSIVE,
invalidReason: response.statusText,
json,
};
}
return { tokenStatus: TokenStatus.VALID, json, invalidReason: null } as const;
}
}
function ensureValidResourceOwner(
resourceOwner: { id: number | null; type: "team" } | { id: number | null; type: "user" }
) {
if (resourceOwner.type === "team") {
throw new Error("Teams are not supported");
} else {
if (!resourceOwner.id) {
throw new Error("resourceOwner should have id set");
}
}
}
/**
* It converts error into a Response
*/
function handleFetchError(e: unknown) {
const myLog = log.getSubLogger({ prefix: ["handleFetchError"] });
myLog.debug("Error", safeStringify(e));
if (e instanceof Error) {
return new Response(JSON.stringify({ myFetchError: e.message }), { status: 500 });
}
return new Response(JSON.stringify({ myFetchError: "UNKNOWN_ERROR" }), { status: 500 });
}