@@ -738,6 +738,169 @@ describe("createOAuthProvider", () => {
738738 expect ( ( await syncValue ( provider . tokens ( ) ) ) ?. access_token ) . toBe ( "tok" ) ;
739739 } ) ;
740740
741+ test ( "keeps the mirror when the auth file holds a JSON array" , async ( ) => {
742+ const home = await tempHome ( ) ;
743+ const provider = await createOAuthProvider ( {
744+ serverName : "linear" ,
745+ serverURL : linear . serverURL ,
746+ redirectUrl : "http://127.0.0.1:1/callback" ,
747+ onAuthURL : ( ) => undefined ,
748+ home,
749+ } ) ;
750+ await saveClient ( provider , clientInfo ( 1 ) ) ;
751+ await provider . saveTokens ( { access_token : "tok" , token_type : "bearer" } ) ;
752+
753+ await writeFile ( authFilePath ( linear , home ) , "[]" ) ;
754+
755+ expect ( ( await syncValue ( provider . tokens ( ) ) ) ?. access_token ) . toBe ( "tok" ) ;
756+ expect ( ( await syncValue ( provider . clientInformation ( ) ) ) ?. client_id ) . toBe (
757+ "client-on-1" ,
758+ ) ;
759+ } ) ;
760+
761+ test ( "treats malformed disk tokens as unreadable and never throws" , async ( ) => {
762+ const malformed = [
763+ `{"tokens":null}` ,
764+ `{"tokens":"tok-a"}` ,
765+ `{"tokens":{}}` ,
766+ `{"tokens":[]}` ,
767+ ] ;
768+ for ( const raw of malformed ) {
769+ const home = await tempHome ( ) ;
770+ const provider = await createOAuthProvider ( {
771+ serverName : "linear" ,
772+ serverURL : linear . serverURL ,
773+ redirectUrl : "http://127.0.0.1:1/callback" ,
774+ onAuthURL : ( ) => undefined ,
775+ home,
776+ } ) ;
777+ await provider . saveTokens ( { access_token : "tok" , token_type : "bearer" } ) ;
778+
779+ await writeFile ( authFilePath ( linear , home ) , raw ) ;
780+
781+ expect ( ( await syncValue ( provider . tokens ( ) ) ) ?. access_token ) . toBe ( "tok" ) ;
782+ expect ( await syncValue ( provider . clientInformation ( ) ) ) . toBeUndefined ( ) ;
783+ }
784+ } ) ;
785+
786+ test ( "never throws when disk holds an other-port client with null tokens" , async ( ) => {
787+ const home = await tempHome ( ) ;
788+ const provider = await createOAuthProvider ( {
789+ serverName : "linear" ,
790+ serverURL : linear . serverURL ,
791+ redirectUrl : "http://127.0.0.1:62000/callback" ,
792+ onAuthURL : ( ) => undefined ,
793+ home,
794+ } ) ;
795+ await saveClient ( provider , clientInfo ( 62000 ) ) ;
796+ await provider . saveTokens ( {
797+ access_token : "tok-a" ,
798+ token_type : "bearer" ,
799+ expires_in : 3600 ,
800+ refresh_token : "ref-a" ,
801+ } ) ;
802+
803+ await writeFile (
804+ authFilePath ( linear , home ) ,
805+ JSON . stringify ( {
806+ clientInformation : clientInfo ( 60435 ) ,
807+ tokens : null ,
808+ } ) ,
809+ ) ;
810+
811+ expect ( ( await syncValue ( provider . tokens ( ) ) ) ?. access_token ) . toBe ( "tok-a" ) ;
812+ expect ( ( await syncValue ( provider . clientInformation ( ) ) ) ?. client_id ) . toBe (
813+ "client-on-62000" ,
814+ ) ;
815+ } ) ;
816+
817+ test ( "a mid-refresh sibling logout is not resurrected by the refresh write" , async ( ) => {
818+ const home = await tempHome ( ) ;
819+ let releaseRefresh ! : ( ) => void ;
820+ const refreshGate = new Promise < void > ( ( resolve ) => {
821+ releaseRefresh = resolve ;
822+ } ) ;
823+ let refreshPosted ! : ( ) => void ;
824+ const refreshPostedGate = new Promise < void > ( ( resolve ) => {
825+ refreshPosted = resolve ;
826+ } ) ;
827+ const fetchFn = async (
828+ url : string | URL ,
829+ init ?: RequestInit ,
830+ ) : Promise < Response > => {
831+ const href = String ( url ) ;
832+ if ( init ?. method === "POST" ) {
833+ refreshPosted ( ) ;
834+ await refreshGate ;
835+ return new Response (
836+ JSON . stringify ( {
837+ access_token : "resurrected" ,
838+ token_type : "bearer" ,
839+ expires_in : 3600 ,
840+ refresh_token : "ref-b" ,
841+ } ) ,
842+ { status : 200 , headers : { "content-type" : "application/json" } } ,
843+ ) ;
844+ }
845+ if ( href . includes ( "oauth-protected-resource" ) ) {
846+ return new Response (
847+ JSON . stringify ( {
848+ resource : "https://mcp.linear.app/mcp" ,
849+ authorization_servers : [ "https://mcp.linear.app" ] ,
850+ } ) ,
851+ { status : 200 , headers : { "content-type" : "application/json" } } ,
852+ ) ;
853+ }
854+ if (
855+ href . includes ( "oauth-authorization-server" ) ||
856+ href . includes ( "openid-configuration" )
857+ ) {
858+ return new Response (
859+ JSON . stringify ( {
860+ issuer : "https://mcp.linear.app" ,
861+ authorization_endpoint : "https://mcp.linear.app/authorize" ,
862+ token_endpoint : "https://mcp.linear.app/oauth/token" ,
863+ response_types_supported : [ "code" ] ,
864+ } ) ,
865+ { status : 200 , headers : { "content-type" : "application/json" } } ,
866+ ) ;
867+ }
868+ return new Response ( null , { status : 404 } ) ;
869+ } ;
870+
871+ const a = await createOAuthProvider ( {
872+ serverName : "linear" ,
873+ serverURL : linear . serverURL ,
874+ redirectUrl : "http://127.0.0.1:62000/callback" ,
875+ onAuthURL : ( ) => undefined ,
876+ home,
877+ fetchFn,
878+ } ) ;
879+ await saveClient ( a , clientInfo ( 62000 ) ) ;
880+ await a . saveTokens ( {
881+ access_token : "stale" ,
882+ token_type : "bearer" ,
883+ expires_in : 1 ,
884+ refresh_token : "ref-a" ,
885+ } ) ;
886+ const b = await createOAuthProvider ( {
887+ serverName : "linear" ,
888+ serverURL : linear . serverURL ,
889+ redirectUrl : "http://127.0.0.1:62000/callback" ,
890+ onAuthURL : ( ) => undefined ,
891+ home,
892+ } ) ;
893+
894+ const pending = a . refreshToken ( "ref-a" ) ;
895+ await refreshPostedGate ;
896+ await b . resetAuthorization ( ) ;
897+ releaseRefresh ( ) ;
898+ await pending ;
899+
900+ expect ( ( await loadAuthState ( linear , home ) ) . tokens ) . toBeUndefined ( ) ;
901+ expect ( await syncValue ( a . tokens ( ) ) ) . toBeUndefined ( ) ;
902+ } ) ;
903+
741904 test ( "keeps the mirror through an unreadable auth file and recovers once readable" , async ( ) => {
742905 const home = await tempHome ( ) ;
743906 const provider = await createOAuthProvider ( {
0 commit comments