| 191 | }; |
| 192 | |
| 193 | const tryRefreshToken = async ( |
| 194 | providerType: SupportedProviderType, |
| 195 | refreshToken: string, |
| 196 | credentials: ProviderCredentials, |
| 197 | ): Promise<OAuthTokenResponse | null> => { |
| 198 | const { clientId, clientSecret, baseUrl } = credentials; |
| 199 | |
| 200 | let url: string; |
| 201 | if (baseUrl) { |
| 202 | // Use a trailing-slash-normalized base so relative paths append correctly, |
| 203 | // preserving any context path (e.g. https://example.com/bitbucket/). |
| 204 | const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; |
| 205 | if (providerType === 'github') { |
| 206 | url = new URL('login/oauth/access_token', base).toString(); |
| 207 | } else if (providerType === 'bitbucket-server') { |
| 208 | url = new URL('rest/oauth2/latest/token', base).toString(); |
| 209 | } else { |
| 210 | url = new URL('oauth/token', base).toString(); |
| 211 | } |
| 212 | } else if (providerType === 'github') { |
| 213 | url = 'https://github.com/login/oauth/access_token'; |
| 214 | } else if (providerType === 'gitlab') { |
| 215 | url = 'https://gitlab.com/oauth/token'; |
| 216 | } else if (providerType === 'bitbucket-cloud') { |
| 217 | url = 'https://bitbucket.org/site/oauth2/access_token'; |
| 218 | } else { |
| 219 | logger.error(`Unsupported provider for token refresh: ${providerType}`); |
| 220 | return null; |
| 221 | } |
| 222 | |
| 223 | // Bitbucket requires client credentials via HTTP Basic Auth rather than request body params. |
| 224 | // @see: https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ |
| 225 | const useBasicAuth = providerType === 'bitbucket-cloud'; |
| 226 | |
| 227 | // Build request body parameters |
| 228 | const bodyParams: Record<string, string> = { |
| 229 | // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-6 (refresh token grant) |
| 230 | grant_type: 'refresh_token', |
| 231 | refresh_token: refreshToken, |
| 232 | }; |
| 233 | |
| 234 | if (!useBasicAuth) { |
| 235 | // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 (client authentication) |
| 236 | bodyParams.client_id = clientId; |
| 237 | bodyParams.client_secret = clientSecret; |
| 238 | } |
| 239 | |
| 240 | // GitLab requires redirect_uri to match the original authorization request |
| 241 | // even when refreshing tokens. Use URL constructor to handle trailing slashes. |
| 242 | if (providerType === 'gitlab') { |
| 243 | bodyParams.redirect_uri = new URL('/api/auth/callback/gitlab', env.AUTH_URL).toString(); |
| 244 | } |
| 245 | |
| 246 | const response = await fetch(url, { |
| 247 | method: 'POST', |
| 248 | headers: { |
| 249 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 250 | 'Accept': 'application/json', |