(
refreshToken: string,
{ scopes: requestedScopes }: { scopes?: string[] } = {},
)
| 283 | } |
| 284 | |
| 285 | export async function refreshOAuthToken( |
| 286 | refreshToken: string, |
| 287 | { scopes: requestedScopes }: { scopes?: string[] } = {}, |
| 288 | ): Promise<OAuthTokens> { |
| 289 | const fallbackScopes = |
| 290 | requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES |
| 291 | const requestBody = { |
| 292 | grant_type: 'refresh_token', |
| 293 | refresh_token: refreshToken, |
| 294 | client_id: getOauthClientId(), |
| 295 | // Request specific scopes, defaulting to the full Noumena auth set. The |
| 296 | // backend's refresh-token grant allows scope expansion beyond what the |
| 297 | // initial authorize granted (see ALLOWED_SCOPE_EXPANSIONS), so this is |
| 298 | // safe even for tokens issued before scopes were added to the app's |
| 299 | // registered oauth_scope. |
| 300 | scope: fallbackScopes.join(' '), |
| 301 | } |
| 302 | |
| 303 | try { |
| 304 | const data = await getIdentityClient().refreshOAuthToken({ |
| 305 | requestBody, |
| 306 | timeout: 15000, |
| 307 | }) |
| 308 | const { |
| 309 | access_token: accessToken, |
| 310 | refresh_token: newRefreshToken = refreshToken, |
| 311 | expires_in: expiresIn, |
| 312 | } = data |
| 313 | |
| 314 | const expiresAt = Date.now() + expiresIn * 1000 |
| 315 | const scopes = parseScopes(data.scope) |
| 316 | const normalizedScopes = scopes.length > 0 ? scopes : fallbackScopes |
| 317 | |
| 318 | logEvent('ncode_oauth_token_refresh_success', {}) |
| 319 | |
| 320 | // Skip the extra /api/oauth/profile round-trip when we already have both |
| 321 | // the global-config profile fields AND the secure-storage subscription data. |
| 322 | // Routine refreshes satisfy both, so we cut ~7M req/day fleet-wide. |
| 323 | // |
| 324 | // Checking secure storage (not just config) matters for the |
| 325 | // CLAUDE_CODE_OAUTH_REFRESH_TOKEN re-login path: installOAuthTokens runs |
| 326 | // performLogout() AFTER we return, wiping secure storage. If we returned |
| 327 | // null for subscriptionType here, saveOAuthTokensIfNeeded would persist |
| 328 | // null ?? (wiped) ?? null = null, and every future refresh would see the |
| 329 | // config guard fields satisfied and skip again, permanently losing the |
| 330 | // subscription type for paying users. By passing through existing values, |
| 331 | // the re-login path writes cached ?? wiped ?? null = cached; and if secure |
| 332 | // storage was already empty we fall through to the fetch. |
| 333 | const config = getGlobalConfig() |
| 334 | const existing = getCurrentOAuthClientSession() |
| 335 | const haveProfileAlready = |
| 336 | config.oauthAccount?.billingType !== undefined && |
| 337 | config.oauthAccount?.accountCreatedAt !== undefined && |
| 338 | config.oauthAccount?.subscriptionCreatedAt !== undefined && |
| 339 | existing?.subscription.subscriptionType != null && |
| 340 | existing?.subscription.rateLimitTier != null |
| 341 | |
| 342 | const profileInfo = haveProfileAlready |
no test coverage detected