(
refreshToken: string,
{ scopes: requestedScopes }: { scopes?: string[] } = {},
)
| 172 | } |
| 173 | |
| 174 | export async function refreshOAuthToken( |
| 175 | refreshToken: string, |
| 176 | { scopes: requestedScopes }: { scopes?: string[] } = {}, |
| 177 | ): Promise<OAuthTokens> { |
| 178 | const requestBody = { |
| 179 | grant_type: 'refresh_token', |
| 180 | refresh_token: refreshToken, |
| 181 | client_id: getOauthConfig().CLIENT_ID, |
| 182 | // Request specific scopes, defaulting to the full Claude AI set. The |
| 183 | // backend's refresh-token grant allows scope expansion beyond what the |
| 184 | // initial authorize granted (see ALLOWED_SCOPE_EXPANSIONS), so this is |
| 185 | // safe even for tokens issued before scopes were added to the app's |
| 186 | // registered oauth_scope. |
| 187 | scope: (requestedScopes?.length |
| 188 | ? requestedScopes |
| 189 | : CLAUDE_AI_OAUTH_SCOPES |
| 190 | ).join(' '), |
| 191 | } |
| 192 | |
| 193 | try { |
| 194 | const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { |
| 195 | headers: { 'Content-Type': 'application/json' }, |
| 196 | timeout: 15000, |
| 197 | }) |
| 198 | |
| 199 | if (response.status !== 200) { |
| 200 | throw new Error(`Token refresh failed: ${response.statusText}`) |
| 201 | } |
| 202 | |
| 203 | const data = response.data as OAuthTokenExchangeResponse |
| 204 | const { |
| 205 | access_token: accessToken, |
| 206 | refresh_token: newRefreshToken = refreshToken, |
| 207 | expires_in: expiresIn, |
| 208 | } = data |
| 209 | |
| 210 | const expiresAt = Date.now() + expiresIn * 1000 |
| 211 | const scopes = parseScopes(data.scope) |
| 212 | |
| 213 | logEvent('tengu_oauth_token_refresh_success', {}) |
| 214 | |
| 215 | // Skip the extra /api/oauth/profile round-trip when we already have both |
| 216 | // the global-config profile fields AND the secure-storage subscription data. |
| 217 | // Routine refreshes satisfy both, so we cut ~7M req/day fleet-wide. |
| 218 | // |
| 219 | // Checking secure storage (not just config) matters for the |
| 220 | // CLAUDE_CODE_OAUTH_REFRESH_TOKEN re-login path: installOAuthTokens runs |
| 221 | // performLogout() AFTER we return, wiping secure storage. If we returned |
| 222 | // null for subscriptionType here, saveOAuthTokensIfNeeded would persist |
| 223 | // null ?? (wiped) ?? null = null, and every future refresh would see the |
| 224 | // config guard fields satisfied and skip again, permanently losing the |
| 225 | // subscription type for paying users. By passing through existing values, |
| 226 | // the re-login path writes cached ?? wiped ?? null = cached; and if secure |
| 227 | // storage was already empty we fall through to the fetch. |
| 228 | const config = getGlobalConfig() |
| 229 | const existing = getClaudeAIOAuthTokens() |
| 230 | const haveProfileAlready = |
| 231 | config.oauthAccount?.billingType !== undefined && |
no test coverage detected