566 lines
22 KiB
TypeScript
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 });
|
||
|
|
}
|