|
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 | | -import { ApiRequestOptions, ApiRequestOutputType, BulkXmlErrorResponse, FetchFn, FetchResponse, Logger, SoapErrorResponse } from './types'; |
| 5 | +import { |
| 6 | + ApiRequestOptions, |
| 7 | + ApiRequestOutputType, |
| 8 | + BulkXmlErrorResponse, |
| 9 | + FetchFn, |
| 10 | + FetchResponse, |
| 11 | + Logger, |
| 12 | + SessionInfo, |
| 13 | + SoapErrorResponse, |
| 14 | +} from './types'; |
5 | 15 |
|
6 | 16 | const SOAP_API_AUTH_ERROR_REGEX = /<faultcode>[a-zA-Z]+:INVALID_SESSION_ID<\/faultcode>/; |
7 | 17 | // Shows up for certain API requests, such as Identity |
@@ -46,9 +56,14 @@ function parseXml(value: string) { |
46 | 56 | export function getApiRequestFactoryFn(fetch: FetchFn) { |
47 | 57 | return ( |
48 | 58 | logger: Logger, |
49 | | - onRefresh?: (accessToken: string) => void, |
| 59 | + onRefresh?: (accessToken: string, refreshToken?: string) => void, |
50 | 60 | onConnectionError?: (accessToken: string) => void, |
| 61 | + /** |
| 62 | + * Enable logging only applies to request/response data |
| 63 | + * other logging for refresh flow and logic errors will still be logged |
| 64 | + */ |
51 | 65 | enableLogging?: boolean, |
| 66 | + getFreshTokens?: () => Promise<Pick<SessionInfo, 'accessToken' | 'refreshToken'> | null>, |
52 | 67 | ) => { |
53 | 68 | const apiRequest = async <Response = unknown>(options: ApiRequestOptions, attemptRefresh = true): Promise<Response> => { |
54 | 69 | // eslint-disable-next-line prefer-const |
@@ -125,18 +140,37 @@ export function getApiRequestFactoryFn(fetch: FetchFn) { |
125 | 140 | sessionInfo.refreshToken |
126 | 141 | ) { |
127 | 142 | 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); |
| 143 | + logger.debug({ url, method, status: response.status }, '[TOKEN REFRESH] Attempting token refresh'); |
| 144 | + const { access_token: newAccessToken, refresh_token: newRefreshToken } = await exchangeRefreshToken(fetch, sessionInfo); |
| 145 | + logger.debug({ url, method, tokenRotated: !!newRefreshToken }, '[TOKEN REFRESH] Token refresh successful'); |
| 146 | + onRefresh?.(newAccessToken, newRefreshToken); |
131 | 147 | // replace token in body |
132 | 148 | if (typeof options.body === 'string' && options.body.includes(accessToken)) { |
133 | 149 | // if the response is soap, we need to return the response as is |
134 | 150 | options.body = options.body.replace(accessToken, newAccessToken); |
135 | 151 | } |
136 | 152 |
|
137 | 153 | return apiRequest({ ...options, sessionInfo: { ...sessionInfo, accessToken: newAccessToken } }, false); |
138 | | - } catch { |
139 | | - logger.warn('Unable to refresh accessToken'); |
| 154 | + } catch (ex) { |
| 155 | + logger.warn({ url, method, ...getErrorMessageAndStackObj(ex) }, '[TOKEN REFRESH] Unable to refresh accessToken'); |
| 156 | + |
| 157 | + // Check if another worker already refreshed (race condition on token rotation). |
| 158 | + // If the DB has a different access token, a concurrent request won the race — retry with fresh tokens. |
| 159 | + if (getFreshTokens) { |
| 160 | + try { |
| 161 | + const freshTokens = await getFreshTokens(); |
| 162 | + if (freshTokens && freshTokens.accessToken !== accessToken) { |
| 163 | + logger.info({ url, method }, '[TOKEN REFRESH] Concurrent refresh detected — retrying with tokens from another worker'); |
| 164 | + return apiRequest({ ...options, sessionInfo: { ...sessionInfo, ...freshTokens } }, false); |
| 165 | + } |
| 166 | + } catch (freshEx) { |
| 167 | + logger.warn( |
| 168 | + { url, method, ...getErrorMessageAndStackObj(freshEx) }, |
| 169 | + '[TOKEN REFRESH] Failed to retrieve fresh tokens for race condition check', |
| 170 | + ); |
| 171 | + } |
| 172 | + } |
| 173 | + |
140 | 174 | responseText = ERROR_MESSAGES.SFDC_EXPIRED_TOKEN; |
141 | 175 | onConnectionError?.(ERROR_MESSAGES.SFDC_EXPIRED_TOKEN); |
142 | 176 | } |
@@ -192,7 +226,10 @@ function handleSalesforceApiError(outputType: ApiRequestOutputType, responseText |
192 | 226 | return output; |
193 | 227 | } |
194 | 228 |
|
195 | | -function exchangeRefreshToken(fetch: FetchFn, sessionInfo: ApiRequestOptions['sessionInfo']): Promise<{ access_token: string }> { |
| 229 | +function exchangeRefreshToken( |
| 230 | + fetch: FetchFn, |
| 231 | + sessionInfo: ApiRequestOptions['sessionInfo'], |
| 232 | +): Promise<{ access_token: string; refresh_token?: string }> { |
196 | 233 | const searchParams = new URLSearchParams({ |
197 | 234 | grant_type: 'refresh_token', |
198 | 235 | }); |
|
0 commit comments