fix(auth): write Claude session-refreshed token back to canonical creds
Root cause of recurring "Refresh token expired / invalid_grant" logouts: the main refresh loop and each agent session's Claude CLI share one OAuth token family but refresh independently. Anthropic rotates refresh tokens and revokes the whole family if an already-rotated token is reused, so when a session refreshed mid-turn the canonical copy went stale and its next refresh was rejected — forcing a manual re-login. Add syncClaudeSessionAuthBack (mirrors the existing Codex syncCodexSessionAuthBack): after each Claude turn, if the session's CLAUDE_CONFIG_DIR credentials are strictly newer than canonical (and same subscription), adopt them into the canonical file. writeCredentials then fans the current token out to every session dir, so no stale copy lingers to trigger family revocation. Decision logic extracted to the pure, tested shouldAdoptSessionOAuth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,22 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyUpdatedTokensToEnvContent,
|
||||
pickFreshestOAuth,
|
||||
shouldAdoptSessionOAuth,
|
||||
shouldStartTokenRefreshLoop,
|
||||
} from './token-refresh.js';
|
||||
|
||||
function creds(expiresAt: number, refreshToken = 'rt') {
|
||||
return {
|
||||
claudeAiOauth: {
|
||||
accessToken: `at-${expiresAt}`,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
scopes: [] as string[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldStartTokenRefreshLoop', () => {
|
||||
it('starts refresh for the Claude service', () => {
|
||||
expect(shouldStartTokenRefreshLoop('claude-code')).toBe(true);
|
||||
@@ -42,3 +55,82 @@ describe('shouldStartTokenRefreshLoop', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickFreshestOAuth', () => {
|
||||
it('adopts a session copy that expires later than the source', () => {
|
||||
const best = pickFreshestOAuth([
|
||||
{ label: 'source', creds: creds(1000) },
|
||||
{ label: 'sessionA', creds: creds(5000) },
|
||||
{ label: 'sessionB', creds: creds(3000) },
|
||||
]);
|
||||
expect(best?.label).toBe('sessionA');
|
||||
});
|
||||
|
||||
it('keeps the source on ties so we do not churn copies needlessly', () => {
|
||||
const best = pickFreshestOAuth([
|
||||
{ label: 'source', creds: creds(5000) },
|
||||
{ label: 'sessionA', creds: creds(5000) },
|
||||
]);
|
||||
expect(best?.label).toBe('source');
|
||||
});
|
||||
|
||||
it('ignores candidates without a refresh token or numeric expiry', () => {
|
||||
const best = pickFreshestOAuth([
|
||||
{ label: 'source', creds: creds(1000) },
|
||||
{ label: 'noRefresh', creds: creds(9000, '') },
|
||||
{ label: 'missing', creds: null },
|
||||
]);
|
||||
expect(best?.label).toBe('source');
|
||||
});
|
||||
|
||||
it('returns null when no candidate is usable', () => {
|
||||
expect(
|
||||
pickFreshestOAuth([
|
||||
{ label: 'source', creds: null },
|
||||
{ label: 'noRefresh', creds: creds(9000, '') },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAdoptSessionOAuth (session→canonical write-back)', () => {
|
||||
const oauth = (expiresAt: number, extra: Record<string, unknown> = {}) => ({
|
||||
accessToken: `at-${expiresAt}`,
|
||||
refreshToken: `rt-${expiresAt}`,
|
||||
expiresAt,
|
||||
scopes: [] as string[],
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('adopts a session token strictly newer than canonical', () => {
|
||||
expect(shouldAdoptSessionOAuth(oauth(2000), oauth(1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not regress to an older or equal session token', () => {
|
||||
expect(shouldAdoptSessionOAuth(oauth(1000), oauth(2000))).toBe(false);
|
||||
expect(shouldAdoptSessionOAuth(oauth(2000), oauth(2000))).toBe(false);
|
||||
});
|
||||
|
||||
it('adopts when canonical is missing/invalid', () => {
|
||||
expect(shouldAdoptSessionOAuth(oauth(1000), null)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an invalid session token', () => {
|
||||
expect(shouldAdoptSessionOAuth(null, oauth(1000))).toBe(false);
|
||||
expect(
|
||||
shouldAdoptSessionOAuth(
|
||||
{ accessToken: '', refreshToken: '', expiresAt: 5000, scopes: [] },
|
||||
oauth(1000),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to cross subscription types', () => {
|
||||
expect(
|
||||
shouldAdoptSessionOAuth(
|
||||
oauth(2000, { subscriptionType: 'pro' }),
|
||||
oauth(1000, { subscriptionType: 'max' }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user