ampcode-connector 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/auth/oauth.ts +98 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth/oauth.ts CHANGED
@@ -36,18 +36,8 @@ export async function token(config: OAuthConfig, account = 0): Promise<string |
36
36
 
37
37
  if (store.fresh(creds)) return creds.accessToken;
38
38
 
39
- try {
40
- return (await refresh(config, creds.refreshToken, account)).accessToken;
41
- } catch (err) {
42
- logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
43
- try {
44
- await Bun.sleep(1000);
45
- return (await refresh(config, creds.refreshToken, account)).accessToken;
46
- } catch (retryErr) {
47
- logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
48
- return null;
49
- }
50
- }
39
+ const refreshed = await refreshWithRetry(config, creds.refreshToken, account);
40
+ return refreshed?.accessToken ?? null;
51
41
  }
52
42
 
53
43
  export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken: string; account: number } | null> {
@@ -61,6 +51,7 @@ export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken:
61
51
  const refreshed = await refresh(config, c.refreshToken, account);
62
52
  return { accessToken: refreshed.accessToken, account };
63
53
  } catch (err) {
54
+ handleRefreshFailure(config, account, err);
64
55
  logger.debug(`${config.providerName}:${account} refresh failed in tokenFromAny`, { error: String(err) });
65
56
  }
66
57
  }
@@ -160,6 +151,29 @@ async function refresh(config: OAuthConfig, refreshToken: string, account = 0):
160
151
  return credentials;
161
152
  }
162
153
 
154
+ async function refreshWithRetry(
155
+ config: OAuthConfig,
156
+ refreshToken: string,
157
+ account: number,
158
+ ): Promise<Credentials | null> {
159
+ try {
160
+ return await refresh(config, refreshToken, account);
161
+ } catch (err) {
162
+ if (handleRefreshFailure(config, account, err)) return null;
163
+
164
+ logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
165
+
166
+ try {
167
+ await Bun.sleep(1000);
168
+ return await refresh(config, refreshToken, account);
169
+ } catch (retryErr) {
170
+ handleRefreshFailure(config, account, retryErr);
171
+ logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
172
+ return null;
173
+ }
174
+ }
175
+ }
176
+
163
177
  async function exchange(config: OAuthConfig, params: Record<string, string>): Promise<Record<string, unknown>> {
164
178
  const all: Record<string, string> = { client_id: config.clientId, ...params };
165
179
  if (config.clientSecret) all.client_secret = config.clientSecret;
@@ -173,12 +187,83 @@ async function exchange(config: OAuthConfig, params: Record<string, string>): Pr
173
187
 
174
188
  if (!res.ok) {
175
189
  const text = await res.text();
176
- throw new Error(`${config.providerName} token exchange failed (${res.status}): ${text}`);
190
+ const oauthError = parseOAuthError(text);
191
+ throw new TokenExchangeError(config.providerName, res.status, text, oauthError.code, oauthError.description);
177
192
  }
178
193
 
179
194
  return (await res.json()) as Record<string, unknown>;
180
195
  }
181
196
 
197
+ class TokenExchangeError extends Error {
198
+ readonly status: number;
199
+ readonly responseBody: string;
200
+ readonly errorCode: string | null;
201
+ readonly errorDescription: string | null;
202
+
203
+ constructor(
204
+ providerName: string,
205
+ status: number,
206
+ responseBody: string,
207
+ errorCode: string | null,
208
+ errorDescription: string | null,
209
+ ) {
210
+ super(`${providerName} token exchange failed (${status}): ${responseBody}`);
211
+ this.name = "TokenExchangeError";
212
+ this.status = status;
213
+ this.responseBody = responseBody;
214
+ this.errorCode = errorCode;
215
+ this.errorDescription = errorDescription;
216
+ }
217
+ }
218
+
219
+ function parseOAuthError(responseBody: string): { code: string | null; description: string | null } {
220
+ try {
221
+ const parsed = JSON.parse(responseBody) as { error?: unknown; error_description?: unknown };
222
+ const code = typeof parsed.error === "string" ? parsed.error : null;
223
+ const description = typeof parsed.error_description === "string" ? parsed.error_description : null;
224
+ return { code, description };
225
+ } catch {
226
+ return { code: null, description: null };
227
+ }
228
+ }
229
+
230
+ /** Handles terminal refresh failures and returns true when retry should stop. */
231
+ function handleRefreshFailure(config: OAuthConfig, account: number, err: unknown): boolean {
232
+ if (!isInvalidRefreshTokenError(err)) return false;
233
+ if (!store.get(config.providerName, account)) return false;
234
+
235
+ store.remove(config.providerName, account);
236
+ logger.warn(`Removed invalid refresh token for ${config.providerName}:${account}; re-login required.`, {
237
+ error: String(err),
238
+ });
239
+ return true;
240
+ }
241
+
242
+ function isInvalidRefreshTokenError(err: unknown): boolean {
243
+ if (!(err instanceof TokenExchangeError)) return false;
244
+ if (err.status !== 400 && err.status !== 401) return false;
245
+
246
+ if (err.errorCode === "invalid_grant") return true;
247
+
248
+ const description = err.errorDescription?.toLowerCase() ?? "";
249
+ const hasRefreshTokenContext = description.includes("refresh token");
250
+ const indicatesInvalidState =
251
+ description.includes("invalid") ||
252
+ description.includes("not found") ||
253
+ description.includes("expired") ||
254
+ description.includes("revoked");
255
+
256
+ if (hasRefreshTokenContext && indicatesInvalidState) return true;
257
+
258
+ const body = err.responseBody.toLowerCase();
259
+ return (
260
+ body.includes("invalid_grant") ||
261
+ body.includes("invalid refresh token") ||
262
+ body.includes("refresh token not found") ||
263
+ body.includes("refresh token is invalid")
264
+ );
265
+ }
266
+
182
267
  function parseTokenFields(raw: Record<string, unknown>, config: OAuthConfig): Credentials {
183
268
  if (typeof raw.access_token !== "string" || !raw.access_token) {
184
269
  throw new Error(`${config.providerName} token response missing access_token`);