-
Notifications
You must be signed in to change notification settings - Fork 11.7k
fix: GoogleCalendar - Ensure that Delegation Credentials are able to reuse access_token #21389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,27 +58,32 @@ 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; | ||
| private useCredentialSync: boolean; | ||
| 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,29 +225,45 @@ 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, | ||
| }) | ||
| ); | ||
|
|
||
| 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 }) { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass refresh token directly from the place we are sure |
||
| 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(), | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't log entire response unnecessarily |
||
| 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); | ||
| } | ||
|
Comment on lines
+536
to
+539
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We report the actual error now instead of going forward with this json and then being unable to parse the obviously invalid token and then throwing 'Invalid token response' error. This was hiding the actual reason behind the error. |
||
| const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json); | ||
| if (!parsedToken.success) { | ||
| myLog.error( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now we report the actual error