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