@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55import { join } from "node:path" ;
66import { getValidAccessToken , OAuthLoginRequiredError , OAUTH_PROVIDERS , refreshAnthropicAccountWithLock } from "../src/oauth" ;
77import { AnthropicTokenError } from "../src/oauth/anthropic" ;
8- import { getAccountCredential , getAccountSet , getCredential , markAccountNeedsReauth , saveCredential } from "../src/oauth/store" ;
8+ import { credentialGeneration , getAccountCredential , getAccountSet , getAuthRefreshIntentPath , getCredential , markAccountNeedsReauth , readOAuthRefreshIntent , saveCredential , writeOAuthRefreshIntent } from "../src/oauth/store" ;
99
1010const origHome = process . env . HOME ;
1111const origOcxHome = process . env . OPENCODEX_HOME ;
@@ -271,14 +271,14 @@ describe("oauth refresh hardening", () => {
271271 } ) ;
272272
273273 test ( "Anthropic transient failures do not mark needsReauth" , async ( ) => {
274- await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
275- const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
276- for ( const error of [
274+ for ( const [ index , error ] of [
277275 new AnthropicTokenError ( "server" , 503 , undefined ) ,
278276 new AnthropicTokenError ( "timeout" , undefined , undefined ) ,
279- ] ) {
277+ ] . entries ( ) ) {
278+ await saveCredential ( "anthropic" , { access : `old-${ index } ` , refresh : `rt-old-${ index } ` , expires : 1 , accountId : `acct-${ index } ` } ) ;
279+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
280280 await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , { ...OAUTH_PROVIDERS . anthropic ! , refresh : async ( ) => { throw error ; } } , getAccountCredential ( "anthropic" , id ) ! ) ) . rejects . toBe ( error ) ;
281- expect ( getAccountSet ( "anthropic" ) ! . accounts [ 0 ] ! . needsReauth ) . toBeUndefined ( ) ;
281+ expect ( getAccountSet ( "anthropic" ) ! . accounts . find ( account => account . id === id ) ! . needsReauth ) . toBeUndefined ( ) ;
282282 }
283283 } ) ;
284284
@@ -290,6 +290,83 @@ describe("oauth refresh hardening", () => {
290290 expect ( getAccountSet ( "anthropic" ) ! . accounts [ 0 ] ! . needsReauth ) . toBe ( true ) ;
291291 } ) ;
292292
293+ test ( "Anthropic never replays an outstanding oauth-source generation across re-entry" , async ( ) => {
294+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-consumed" , expires : 1 , accountId : "acct" } ) ;
295+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
296+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
297+ writeOAuthRefreshIntent ( "anthropic" , id , credentialGeneration ( credential ) , Date . now ( ) - 120_001 ) ;
298+ let refreshCalls = 0 ;
299+
300+ const attempt = ( ) => refreshAnthropicAccountWithLock ( "anthropic" , id , {
301+ ...OAUTH_PROVIDERS . anthropic ! ,
302+ refresh : async ( ) => { refreshCalls ++ ; throw new Error ( "must not replay" ) ; } ,
303+ } , credential ) ;
304+
305+ await expect ( attempt ( ) ) . rejects . toBeInstanceOf ( OAuthLoginRequiredError ) ;
306+ await expect ( attempt ( ) ) . rejects . toBeInstanceOf ( OAuthLoginRequiredError ) ;
307+
308+ expect ( refreshCalls ) . toBe ( 0 ) ;
309+ expect ( getAccountSet ( "anthropic" ) ! . accounts [ 0 ] ! . needsReauth ) . toBe ( true ) ;
310+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ?. generation ) . toBe ( credentialGeneration ( credential ) ) ;
311+ } ) ;
312+
313+ test ( "Anthropic treats a corrupt durable intent as outstanding and never refreshes" , async ( ) => {
314+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-consumed" , expires : 1 , accountId : "acct" } ) ;
315+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
316+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
317+ writeFileSync ( getAuthRefreshIntentPath ( "anthropic" , id ) , "not-json" ) ;
318+ let refreshCalls = 0 ;
319+
320+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
321+ ...OAUTH_PROVIDERS . anthropic ! ,
322+ refresh : async ( ) => { refreshCalls ++ ; throw new Error ( "must not replay" ) ; } ,
323+ } , credential ) ) . rejects . toBeInstanceOf ( OAuthLoginRequiredError ) ;
324+
325+ expect ( refreshCalls ) . toBe ( 0 ) ;
326+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ?. uncertain ) . toBe ( true ) ;
327+ } ) ;
328+
329+ test ( "Anthropic outstanding intent adopts a newer Claude credential without replay" , async ( ) => {
330+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-consumed" , expires : 1 , source : "local-cli" } ) ;
331+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
332+ const credential = getAccountCredential ( "anthropic" , id ) ! ;
333+ writeOAuthRefreshIntent ( "anthropic" , id , credentialGeneration ( credential ) ) ;
334+ seedClaudeCredentials ( "disk" , "rt-new" , Date . now ( ) + 3600_000 ) ;
335+ let refreshCalls = 0 ;
336+
337+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , {
338+ ...OAUTH_PROVIDERS . anthropic ! ,
339+ refresh : async ( ) => { refreshCalls ++ ; throw new Error ( "must not replay" ) ; } ,
340+ } , credential ) ) . resolves . toBe ( "disk" ) ;
341+
342+ expect ( refreshCalls ) . toBe ( 0 ) ;
343+ expect ( getAccountCredential ( "anthropic" , id ) ?. refresh ) . toBe ( "rt-new" ) ;
344+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toBeUndefined ( ) ;
345+ } ) ;
346+
347+ test ( "Anthropic successful refresh clears its intent and the new generation can refresh" , async ( ) => {
348+ await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
349+ const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
350+ const calls : string [ ] = [ ] ;
351+ const def = {
352+ ...OAUTH_PROVIDERS . anthropic ! ,
353+ refresh : async ( refresh : string ) => {
354+ calls . push ( refresh ) ;
355+ return calls . length === 1
356+ ? { access : "fresh-1" , refresh : "rt-new-1" , expires : 1 }
357+ : { access : "fresh-2" , refresh : "rt-new-2" , expires : Date . now ( ) + 3600_000 } ;
358+ } ,
359+ } ;
360+
361+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , def , getAccountCredential ( "anthropic" , id ) ! ) ) . resolves . toBe ( "fresh-1" ) ;
362+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toBeUndefined ( ) ;
363+ await expect ( refreshAnthropicAccountWithLock ( "anthropic" , id , def , getAccountCredential ( "anthropic" , id ) ! ) ) . resolves . toBe ( "fresh-2" ) ;
364+
365+ expect ( calls ) . toEqual ( [ "rt-old" , "rt-new-1" ] ) ;
366+ expect ( getAccountCredential ( "anthropic" , id ) ?. refresh ) . toBe ( "rt-new-2" ) ;
367+ expect ( readOAuthRefreshIntent ( "anthropic" , id ) ) . toBeUndefined ( ) ;
368+ } ) ;
369+
293370 test ( "Anthropic late terminal failure does not mark a superseding generation" , async ( ) => {
294371 await saveCredential ( "anthropic" , { access : "old" , refresh : "rt-old" , expires : 1 , accountId : "acct" } ) ;
295372 const id = getAccountSet ( "anthropic" ) ! . activeAccountId ;
0 commit comments