@rikcodes/teamclaude 1.1.14-rik.1 → 1.1.14-rik.2

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 (3) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/oauth.js +56 -14
package/README.md CHANGED
@@ -57,8 +57,9 @@ teamclaude run # in another terminal: Claude Code through the proxy
57
57
  Already logged into Claude Code? `teamclaude import` takes its credentials instead of a fresh OAuth round. API keys, and one email holding accounts in several orgs, are covered in [docs/accounts.md](docs/accounts.md).
58
58
 
59
59
  `teamclaude run` does not require a separate Claude Code login for normal API
60
- requests. When local Claude OAuth is unavailable or expired, proxy credential
61
- mode supplies a local-only bootstrap key; the proxy replaces that placeholder
60
+ requests. When there is no usable local Claude OAuth (no login, or one that can
61
+ no longer be refreshed), proxy credential mode supplies a local-only bootstrap
62
+ key; the proxy replaces that placeholder
62
63
  with the selected TeamClaude account credential before forwarding.
63
64
 
64
65
  ## What it does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.14-rik.1",
3
+ "version": "1.1.14-rik.2",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/oauth.js CHANGED
@@ -67,22 +67,48 @@ export async function readKeychainCredentials({ exec = execFileAsync, username =
67
67
 
68
68
  /**
69
69
  * Import OAuth credentials from a Claude Code credentials file.
70
- * On macOS the default credentials location is the Keychain, not a file, so
71
- * when the default path is missing the Keychain is tried before giving up.
70
+ *
71
+ * On macOS Claude Code keeps its live login in the Keychain, and
72
+ * ~/.claude/.credentials.json — when it exists at all — is a snapshot from an
73
+ * earlier login that Claude Code never refreshes. So for the default path on
74
+ * darwin the Keychain is asked first, and the file is only the fallback for a
75
+ * Keychain that carries no token or cannot be read. Reading the file first made
76
+ * a days-old snapshot look like an expired login while Claude Code itself was
77
+ * still signed in. Any other path is a plain file read.
72
78
  */
73
79
  export async function importCredentials(filePath, {
74
80
  home = homedir(), platform = process.platform, readKeychain = readKeychainCredentials } = {}) {
75
81
  const resolvedPath = filePath.replace(/^~/, home);
76
- let raw;
77
- try {
78
- raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
79
- } catch (err) {
80
- const isDefaultPath = resolvedPath === DEFAULT_CREDENTIALS_PATH.replace(/^~/, home);
81
- if (err.code !== 'ENOENT' || platform !== 'darwin' || !isDefaultPath) throw err;
82
+ const isDefaultPath = resolvedPath === DEFAULT_CREDENTIALS_PATH.replace(/^~/, home);
83
+ const useKeychain = platform === 'darwin' && isDefaultPath;
84
+
85
+ let raw = null;
86
+ let keychainBlank = null; // a Keychain payload that parsed but carried no token
87
+ let keychainErr = null;
88
+ if (useKeychain) {
89
+ try {
90
+ const payload = await readKeychain();
91
+ if (hasToken(payload)) raw = payload;
92
+ else keychainBlank = payload;
93
+ } catch (err) {
94
+ keychainErr = err;
95
+ }
96
+ }
97
+
98
+ if (!raw) {
82
99
  try {
83
- raw = await readKeychain();
84
- } catch (kcErr) {
85
- throw new Error(`${err.message}; macOS Keychain lookup for "${KEYCHAIN_SERVICE}" also failed: ${kcErr.message}`);
100
+ raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
101
+ } catch (err) {
102
+ if (!useKeychain || err.code !== 'ENOENT') throw err;
103
+ // No file either. A token-less Keychain payload still goes back to the
104
+ // caller, whose own "no credentials" reporting stays in charge; only a
105
+ // Keychain that could not be read at all is an error here.
106
+ if (keychainBlank) {
107
+ raw = keychainBlank;
108
+ } else {
109
+ const detail = keychainErr ? keychainErr.message : 'no item carried a token';
110
+ throw new Error(`${err.message}; macOS Keychain lookup for "${KEYCHAIN_SERVICE}" also failed: ${detail}`);
111
+ }
86
112
  }
87
113
  }
88
114
 
@@ -92,6 +118,9 @@ export async function importCredentials(filePath, {
92
118
  accessToken: data.accessToken,
93
119
  refreshToken: data.refreshToken,
94
120
  expiresAt: data.expiresAt,
121
+ // Only Claude Code's own store carries this; leave the key out rather than
122
+ // put an undefined one on every imported account.
123
+ ...(data.refreshTokenExpiresAt != null && { refreshTokenExpiresAt: data.refreshTokenExpiresAt }),
95
124
  subscriptionType: data.subscriptionType,
96
125
  rateLimitTier: data.rateLimitTier,
97
126
  };
@@ -204,10 +233,23 @@ export function isTokenExpired(expiresAt) {
204
233
  return Date.now() >= normalizeExpiresAt(expiresAt);
205
234
  }
206
235
 
236
+ /**
237
+ * Whether Claude Code has to start in proxy credential mode (on the bootstrap
238
+ * key) because it has no usable OAuth of its own.
239
+ *
240
+ * "Usable" is deliberately loose. An access token that has already expired is
241
+ * fine as long as a refresh token is there: Claude Code refreshes the pair
242
+ * itself at startup, and the proxy relays that refresh untouched. Counting an
243
+ * expired access token as "no OAuth" put Claude Code into API-key mode — which
244
+ * drops subscription mode and disables Claude in Chrome — whenever the
245
+ * credentials it read were a stale snapshot. Only a missing, or itself
246
+ * expired, refresh token means Claude Code cannot sign in on its own.
247
+ */
207
248
  export function needsProxyClientCredential(credentials, now = Date.now()) {
208
- if (!credentials?.accessToken) return true;
209
- if (!credentials.expiresAt) return false;
210
- return normalizeExpiresAt(credentials.expiresAt) <= now;
249
+ if (!credentials) return true;
250
+ const { accessToken, expiresAt, refreshToken, refreshTokenExpiresAt } = credentials;
251
+ const live = (token, expiry) => Boolean(token) && (!expiry || normalizeExpiresAt(expiry) > now);
252
+ return !live(accessToken, expiresAt) && !live(refreshToken, refreshTokenExpiresAt);
211
253
  }
212
254
 
213
255
  /**