|
1 | 1 | import { ERROR_MESSAGES, HTTP } from '@jetstream/shared/constants'; |
| 2 | +import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; |
2 | 3 | import { parse } from '@jetstreamapp/simple-xml'; |
3 | 4 | import isObject from 'lodash/isObject'; |
4 | 5 | import { ApiRequestOptions, ApiRequestOutputType, BulkXmlErrorResponse, FetchFn, FetchResponse, Logger, SoapErrorResponse } from './types'; |
@@ -46,9 +47,14 @@ function parseXml(value: string) { |
46 | 47 | export function getApiRequestFactoryFn(fetch: FetchFn) { |
47 | 48 | return ( |
48 | 49 | logger: Logger, |
49 | | - onRefresh?: (accessToken: string) => void, |
50 | | - onConnectionError?: (accessToken: string) => void, |
| 50 | + onRefresh?: (accessToken: string, refreshToken?: string) => void, |
| 51 | + onConnectionError?: (error: string) => void, |
| 52 | + /** |
| 53 | + * Enable logging only applies to request/response data |
| 54 | + * other logging for refresh flow and logic errors will still be logged |
| 55 | + */ |
51 | 56 | enableLogging?: boolean, |
| 57 | + getFreshTokens?: () => Promise<{ accessToken: string; refreshToken: string } | null>, |
52 | 58 | ) => { |
53 | 59 | const apiRequest = async <Response = unknown>(options: ApiRequestOptions, attemptRefresh = true): Promise<Response> => { |
54 | 60 | // eslint-disable-next-line prefer-const |
@@ -125,18 +131,37 @@ export function getApiRequestFactoryFn(fetch: FetchFn) { |
125 | 131 | sessionInfo.refreshToken |
126 | 132 | ) { |
127 | 133 | try { |
128 | | - // if 401 and we have a refresh token, then attempt to refresh the token |
129 | | - const { access_token: newAccessToken } = await exchangeRefreshToken(fetch, sessionInfo); |
130 | | - onRefresh?.(newAccessToken); |
| 134 | + logger.debug({ url, method, status: response.status }, '[TOKEN REFRESH] Attempting token refresh'); |
| 135 | + const { access_token: newAccessToken, refresh_token: newRefreshToken } = await exchangeRefreshToken(fetch, sessionInfo); |
| 136 | + logger.debug({ url, method, tokenRotated: !!newRefreshToken }, '[TOKEN REFRESH] Token refresh successful'); |
| 137 | + onRefresh?.(newAccessToken, newRefreshToken); |
131 | 138 | // replace token in body |
132 | 139 | if (typeof options.body === 'string' && options.body.includes(accessToken)) { |
133 | 140 | // if the response is soap, we need to return the response as is |
134 | 141 | options.body = options.body.replace(accessToken, newAccessToken); |
135 | 142 | } |
136 | 143 |
|
137 | 144 | return apiRequest({ ...options, sessionInfo: { ...sessionInfo, accessToken: newAccessToken } }, false); |
138 | | - } catch { |
139 | | - logger.warn('Unable to refresh accessToken'); |
| 145 | + } catch (ex) { |
| 146 | + logger.warn({ url, method, ...getErrorMessageAndStackObj(ex) }, '[TOKEN REFRESH] Unable to refresh accessToken'); |
| 147 | + |
| 148 | + // Check if another worker already refreshed (race condition on token rotation). |
| 149 | + // If the DB has a different access token, a concurrent request won the race — retry with fresh tokens. |
| 150 | + if (getFreshTokens) { |
| 151 | + try { |
| 152 | + const freshTokens = await getFreshTokens(); |
| 153 | + if (freshTokens && freshTokens.accessToken !== accessToken) { |
| 154 | + logger.info({ url, method }, '[TOKEN REFRESH] Concurrent refresh detected — retrying with tokens from another worker'); |
| 155 | + return apiRequest({ ...options, sessionInfo: { ...sessionInfo, ...freshTokens } }, false); |
| 156 | + } |
| 157 | + } catch (freshEx) { |
| 158 | + logger.warn( |
| 159 | + { url, method, ...getErrorMessageAndStackObj(freshEx) }, |
| 160 | + '[TOKEN REFRESH] Failed to retrieve fresh tokens for race condition check', |
| 161 | + ); |
| 162 | + } |
| 163 | + } |
| 164 | + |
140 | 165 | responseText = ERROR_MESSAGES.SFDC_EXPIRED_TOKEN; |
141 | 166 | onConnectionError?.(ERROR_MESSAGES.SFDC_EXPIRED_TOKEN); |
142 | 167 | } |
@@ -192,7 +217,10 @@ function handleSalesforceApiError(outputType: ApiRequestOutputType, responseText |
192 | 217 | return output; |
193 | 218 | } |
194 | 219 |
|
195 | | -function exchangeRefreshToken(fetch: FetchFn, sessionInfo: ApiRequestOptions['sessionInfo']): Promise<{ access_token: string }> { |
| 220 | +function exchangeRefreshToken( |
| 221 | + fetch: FetchFn, |
| 222 | + sessionInfo: ApiRequestOptions['sessionInfo'], |
| 223 | +): Promise<{ access_token: string; refresh_token?: string }> { |
196 | 224 | const searchParams = new URLSearchParams({ |
197 | 225 | grant_type: 'refresh_token', |
198 | 226 | }); |
@@ -221,6 +249,6 @@ function exchangeRefreshToken(fetch: FetchFn, sessionInfo: ApiRequestOptions['se |
221 | 249 | }) |
222 | 250 | .then((response) => response.json()) |
223 | 251 | .then((response) => { |
224 | | - return response as { access_token: string }; |
| 252 | + return response as { access_token: string; refresh_token?: string }; |
225 | 253 | }); |
226 | 254 | } |
0 commit comments