( auth: SapConcurAuth, requestId: string )
| 197 | * Validates that the geolocation returned by Concur is a safe external URL. |
| 198 | */ |
| 199 | export async function fetchSapConcurAccessToken( |
| 200 | auth: SapConcurAuth, |
| 201 | requestId: string |
| 202 | ): Promise<{ accessToken: string; geolocation: string }> { |
| 203 | if (auth.grantType === 'password') { |
| 204 | if (!auth.username) throw new Error('username is required for password grant') |
| 205 | if (!auth.password) throw new Error('password is required for password grant') |
| 206 | } |
| 207 | |
| 208 | const cacheKey = tokenCacheKey(auth) |
| 209 | const cached = TOKEN_CACHE.get(cacheKey) |
| 210 | if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { |
| 211 | return { accessToken: cached.accessToken, geolocation: cached.geolocation } |
| 212 | } |
| 213 | |
| 214 | const tokenUrl = assertSafeExternalUrl( |
| 215 | `https://${auth.datacenter}/oauth2/v0/token`, |
| 216 | 'tokenUrl' |
| 217 | ).toString() |
| 218 | |
| 219 | const params = new URLSearchParams() |
| 220 | params.set('client_id', auth.clientId) |
| 221 | params.set('client_secret', auth.clientSecret) |
| 222 | params.set('grant_type', auth.grantType) |
| 223 | if (auth.grantType === 'password') { |
| 224 | params.set('username', auth.username ?? '') |
| 225 | params.set('password', auth.password ?? '') |
| 226 | if (auth.companyUuid) params.set('credtype', 'authtoken') |
| 227 | } |
| 228 | |
| 229 | const response = await secureFetchWithValidation( |
| 230 | tokenUrl, |
| 231 | { |
| 232 | method: 'POST', |
| 233 | headers: { |
| 234 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 235 | Accept: 'application/json', |
| 236 | }, |
| 237 | body: params.toString(), |
| 238 | timeout: SAP_CONCUR_OUTBOUND_FETCH_TIMEOUT_MS, |
| 239 | }, |
| 240 | 'tokenUrl' |
| 241 | ) |
| 242 | |
| 243 | if (!response.ok) { |
| 244 | const text = await response.text().catch(() => '') |
| 245 | logger.warn(`[${requestId}] Concur token fetch failed (${response.status}): ${text}`) |
| 246 | throw new Error(`Concur token request failed: HTTP ${response.status}`) |
| 247 | } |
| 248 | |
| 249 | const data = (await response.json()) as { |
| 250 | access_token?: string |
| 251 | expires_in?: number |
| 252 | geolocation?: string |
| 253 | } |
| 254 | |
| 255 | if (!data.access_token) { |
| 256 | throw new Error('Concur token response missing access_token') |
no test coverage detected