11import { describe , expect , test } from "bun:test" ;
22import { mkdir , mkdtemp , readFile , writeFile } from "node:fs/promises" ;
33import { tmpdir } from "node:os" ;
4- import { join } from "node:path" ;
4+ import { dirname , join } from "node:path" ;
55import {
6+ authFilePath ,
67 deleteAuthState ,
78 loadAuthState ,
89 saveAuthState ,
@@ -33,9 +34,10 @@ describe("mcp auth-store", () => {
3334 home ,
3435 ) ;
3536
36- // Reproduce the bug: one writer saves tokens while another overwrites with a
37- // fresh codeVerifier from a concurrent OAuth start. With full-snapshot
38- // persists, the verifier write wiped tokens; with updateAuthState, both land.
37+ // Reproduce the bug: one writer saves tokens while another updates the
38+ // client registration from a concurrent OAuth start. With full-snapshot
39+ // persists, the registration write wiped tokens; with updateAuthState,
40+ // both land.
3941 const writes = await Promise . all ( [
4042 updateAuthState (
4143 linear ,
@@ -52,23 +54,28 @@ describe("mcp auth-store", () => {
5254 updateAuthState (
5355 linear ,
5456 ( state ) => {
55- state . codeVerifier = "verifier-from-other-session" ;
57+ state . clientInformation = {
58+ client_id : "c1" ,
59+ redirect_uris : [ "http://127.0.0.1:1/callback" ] ,
60+ client_id_issued_at : 1 ,
61+ client_name : "other-session" ,
62+ } ;
5663 } ,
5764 home ,
5865 ) ,
5966 ] ) ;
6067
6168 const final = await loadAuthState ( linear , home ) ;
6269 expect ( final . tokens ?. access_token ) . toBe ( "tok" ) ;
63- expect ( final . codeVerifier ) . toBe ( "verifier-from- other-session" ) ;
70+ expect ( final . clientInformation ?. client_name ) . toBe ( "other-session" ) ;
6471 expect ( final . clientInformation ?. client_id ) . toBe ( "c1" ) ;
6572 expect (
6673 writes [ 0 ] . tokens ?. access_token === "tok" ||
6774 writes [ 1 ] . tokens ?. access_token === "tok" ,
6875 ) . toBe ( true ) ;
6976 } ) ;
7077
71- test ( "overlapping updateAuthState from two processes keeps tokens and PKCE " , async ( ) => {
78+ test ( "overlapping updateAuthState from two processes keeps tokens and client registration " , async ( ) => {
7279 const home = await tempHome ( ) ;
7380 await saveAuthState (
7481 linear ,
@@ -103,7 +110,12 @@ describe("mcp auth-store", () => {
103110 refresh_token: "ref",
104111 };
105112 } else {
106- state.codeVerifier = "verifier-from-other-session";
113+ state.clientInformation = {
114+ client_id: "c1",
115+ redirect_uris: ["http://127.0.0.1:1/callback"],
116+ client_id_issued_at: 1,
117+ client_name: "other-session",
118+ };
107119 }
108120 },
109121 home,
@@ -118,7 +130,7 @@ describe("mcp auth-store", () => {
118130 } ,
119131 ) ,
120132 Bun . spawn (
121- [ process . execPath , "-e" , script , "--" , home , "verifier " , barrier ] ,
133+ [ process . execPath , "-e" , script , "--" , home , "client " , barrier ] ,
122134 {
123135 stdout : "ignore" ,
124136 stderr : "pipe" ,
@@ -135,27 +147,33 @@ describe("mcp auth-store", () => {
135147
136148 const final = await loadAuthState ( linear , home ) ;
137149 expect ( final . tokens ?. access_token ) . toBe ( "tok" ) ;
138- expect ( final . codeVerifier ) . toBe ( "verifier-from- other-session" ) ;
150+ expect ( final . clientInformation ?. client_name ) . toBe ( "other-session" ) ;
139151 expect ( final . clientInformation ?. client_id ) . toBe ( "c1" ) ;
140152 } ) ;
141153
142154 test ( "concurrent saveAuthState calls do not throw ENOENT on temp rename" , async ( ) => {
143155 const home = await tempHome ( ) ;
144156 await Promise . all (
145157 Array . from ( { length : 20 } , ( _ , i ) =>
146- saveAuthState ( linear , { codeVerifier : `v${ String ( i ) } ` } , home ) ,
158+ saveAuthState (
159+ linear ,
160+ {
161+ tokens : { access_token : `v${ String ( i ) } ` , token_type : "bearer" } ,
162+ } ,
163+ home ,
164+ ) ,
147165 ) ,
148166 ) ;
149167 const final = await loadAuthState ( linear , home ) ;
150- expect ( final . codeVerifier ?. startsWith ( "v" ) ) . toBe ( true ) ;
168+ expect ( final . tokens ?. access_token ?. startsWith ( "v" ) ) . toBe ( true ) ;
151169 // No leftover temp files from failed renames.
152170 const dir = join ( home , ".corbits" , "mcp-auth" ) ;
153171 const files = await Array . fromAsync (
154172 new Bun . Glob ( "linear-*.json" ) . scan ( dir ) ,
155173 ) ;
156174 expect ( files ) . toHaveLength ( 1 ) ;
157175 const raw = await readFile ( join ( dir , files [ 0 ] ?? "missing" ) , "utf8" ) ;
158- expect ( JSON . parse ( raw ) . codeVerifier ) . toBe ( final . codeVerifier ) ;
176+ expect ( JSON . parse ( raw ) . tokens ) . toEqual ( final . tokens ) ;
159177 } ) ;
160178
161179 test ( "scopes credentials to normalized endpoint identity" , async ( ) => {
@@ -175,13 +193,21 @@ describe("mcp auth-store", () => {
175193 serverURL : "https://ONE.example:443/mcp#ignored" ,
176194 } ;
177195
178- await saveAuthState ( originA , { codeVerifier : "only-a" } , home ) ;
196+ await saveAuthState (
197+ originA ,
198+ { tokens : { access_token : "only-a" , token_type : "bearer" } } ,
199+ home ,
200+ ) ;
179201
180- expect ( ( await loadAuthState ( originA , home ) ) . codeVerifier ) . toBe ( "only-a" ) ;
202+ expect ( ( await loadAuthState ( originA , home ) ) . tokens ?. access_token ) . toBe (
203+ "only-a" ,
204+ ) ;
181205 expect ( await loadAuthState ( originB , home ) ) . toEqual ( { } ) ;
182206 expect ( await loadAuthState ( pathB , home ) ) . toEqual ( { } ) ;
183207 expect ( await loadAuthState ( queryB , home ) ) . toEqual ( { } ) ;
184- expect ( ( await loadAuthState ( equivalent , home ) ) . codeVerifier ) . toBe ( "only-a" ) ;
208+ expect ( ( await loadAuthState ( equivalent , home ) ) . tokens ?. access_token ) . toBe (
209+ "only-a" ,
210+ ) ;
185211 } ) ;
186212
187213 test ( "ignores legacy name-only auth state without modifying it" , async ( ) => {
@@ -204,14 +230,42 @@ describe("mcp auth-store", () => {
204230 } ) ;
205231 } ) ;
206232
233+ test ( "updateAuthState strips a legacy PKCE verifier instead of persisting it" , async ( ) => {
234+ const home = await tempHome ( ) ;
235+ const path = authFilePath ( linear , home ) ;
236+ await mkdir ( dirname ( path ) , { recursive : true } ) ;
237+ await writeFile (
238+ path ,
239+ JSON . stringify ( {
240+ tokens : { access_token : "tok" , token_type : "bearer" } ,
241+ codeVerifier : "legacy-secret" ,
242+ } ) ,
243+ ) ;
244+
245+ const written = await updateAuthState (
246+ linear ,
247+ ( state ) => {
248+ state . tokens = { access_token : "tok2" , token_type : "bearer" } ;
249+ } ,
250+ home ,
251+ ) ;
252+
253+ expect ( written . tokens ?. access_token ) . toBe ( "tok2" ) ;
254+ expect ( "codeVerifier" in written ) . toBe ( false ) ;
255+ expect ( await readFile ( path , "utf8" ) ) . not . toContain ( "codeVerifier" ) ;
256+ expect ( ( await loadAuthState ( linear , home ) ) . tokens ?. access_token ) . toBe (
257+ "tok2" ,
258+ ) ;
259+ } ) ;
260+
207261 test ( "bounds the display slug without weakening scoped identity" , async ( ) => {
208262 const home = await tempHome ( ) ;
209263 const dir = join ( home , ".corbits" , "mcp-auth" ) ;
210264 const prefixName = "a" . repeat ( 48 ) ;
211265 const longName = `${ prefixName } /long` ;
212266 await saveAuthState (
213267 { serverName : longName , serverURL : "https://long.example/mcp" } ,
214- { codeVerifier : "scoped-secret" } ,
268+ { tokens : { access_token : "scoped-secret" , token_type : "bearer" } } ,
215269 home ,
216270 ) ;
217271
@@ -225,8 +279,14 @@ describe("mcp auth-store", () => {
225279
226280 test ( "deleteAuthState removes an existing file" , async ( ) => {
227281 const home = await tempHome ( ) ;
228- await saveAuthState ( linear , { codeVerifier : "secret" } , home ) ;
229- expect ( ( await loadAuthState ( linear , home ) ) . codeVerifier ) . toBe ( "secret" ) ;
282+ await saveAuthState (
283+ linear ,
284+ { tokens : { access_token : "secret" , token_type : "bearer" } } ,
285+ home ,
286+ ) ;
287+ expect ( ( await loadAuthState ( linear , home ) ) . tokens ?. access_token ) . toBe (
288+ "secret" ,
289+ ) ;
230290
231291 await deleteAuthState ( linear , home ) ;
232292 expect ( await loadAuthState ( linear , home ) ) . toEqual ( { } ) ;
@@ -241,13 +301,17 @@ describe("mcp auth-store", () => {
241301
242302 test ( "concurrent updateAuthState and deleteAuthState do not throw ENOENT on rename" , async ( ) => {
243303 const home = await tempHome ( ) ;
244- await saveAuthState ( linear , { codeVerifier : "v0" } , home ) ;
304+ await saveAuthState (
305+ linear ,
306+ { tokens : { access_token : "v0" , token_type : "bearer" } } ,
307+ home ,
308+ ) ;
245309
246310 await Promise . all ( [
247311 updateAuthState (
248312 linear ,
249313 ( state ) => {
250- state . codeVerifier = "v1" ;
314+ state . tokens = { access_token : "v1" , token_type : "bearer" } ;
251315 } ,
252316 home ,
253317 ) ,
@@ -256,7 +320,7 @@ describe("mcp auth-store", () => {
256320
257321 const final = await loadAuthState ( linear , home ) ;
258322 expect (
259- final . codeVerifier === undefined || final . codeVerifier === "v1" ,
323+ final . tokens === undefined || final . tokens . access_token === "v1" ,
260324 ) . toBe ( true ) ;
261325 } ) ;
262326} ) ;
0 commit comments