@@ -78,6 +78,31 @@ describe('crossAppAccess', () => {
7878 expect ( body . get ( 'scope' ) ) . toBeNull ( ) ;
7979 } ) ;
8080
81+ it ( 'omits client_secret from body when not provided (public client)' , async ( ) => {
82+ const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
83+ ok : true ,
84+ json : async ( ) => ( {
85+ issued_token_type : 'urn:ietf:params:oauth:token-type:id-jag' ,
86+ access_token : 'jag-token' ,
87+ token_type : 'N_A'
88+ } )
89+ } as Response ) ;
90+
91+ await requestJwtAuthorizationGrant ( {
92+ tokenEndpoint : 'https://idp.example.com/token' ,
93+ audience : 'https://auth.chat.example/' ,
94+ resource : 'https://mcp.chat.example/' ,
95+ idToken : 'id-token' ,
96+ clientId : 'public-client' ,
97+ fetchFn : mockFetch
98+ } ) ;
99+
100+ const body = new URLSearchParams ( mockFetch . mock . calls [ 0 ] ! [ 1 ] ?. body as string ) ;
101+ expect ( body . get ( 'client_id' ) ) . toBe ( 'public-client' ) ;
102+ // Must be absent — not empty string, not the literal "undefined"
103+ expect ( body . has ( 'client_secret' ) ) . toBe ( false ) ;
104+ } ) ;
105+
81106 it ( 'throws error when issued_token_type is incorrect' , async ( ) => {
82107 const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
83108 ok : true ,
@@ -98,30 +123,32 @@ describe('crossAppAccess', () => {
98123 clientSecret : 'secret' ,
99124 fetchFn : mockFetch
100125 } )
101- ) . rejects . toThrow ( " Invalid issued_token_type: expected 'urn:ietf:params:oauth:token-type:id-jag'" ) ;
126+ ) . rejects . toThrow ( ' Invalid token exchange response' ) ;
102127 } ) ;
103128
104- it ( 'throws error when token_type is incorrect' , async ( ) => {
129+ it ( 'accepts token_type other than N_A (issued_token_type is the real check)' , async ( ) => {
130+ // RFC 6749 §5.1: token_type is case-insensitive; RFC 8693 §2.2.1: informational
131+ // when the issued token isn't an access token. Real IdPs return 'n_a', 'Bearer', etc.
105132 const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
106133 ok : true ,
107134 json : async ( ) => ( {
108135 issued_token_type : 'urn:ietf:params:oauth:token-type:id-jag' ,
109- access_token : 'token' ,
110- token_type : 'Bearer '
136+ access_token : 'jag- token' ,
137+ token_type : 'n_a '
111138 } )
112139 } as Response ) ;
113140
114- await expect (
115- requestJwtAuthorizationGrant ( {
116- tokenEndpoint : 'https://idp.example.com/token ' ,
117- audience : 'https://auth .chat.example/' ,
118- resource : 'https://mcp.chat.example/ ' ,
119- idToken : 'id-token ' ,
120- clientId : 'client ' ,
121- clientSecret : 'secret' ,
122- fetchFn : mockFetch
123- } )
124- ) . rejects . toThrow ( "Invalid token_type: expected 'N_A'" ) ;
141+ const result = await requestJwtAuthorizationGrant ( {
142+ tokenEndpoint : 'https://idp.example.com/token' ,
143+ audience : 'https://auth.chat.example/ ' ,
144+ resource : 'https://mcp .chat.example/' ,
145+ idToken : 'id-token ' ,
146+ clientId : 'client ' ,
147+ clientSecret : 'secret ' ,
148+ fetchFn : mockFetch
149+ } ) ;
150+
151+ expect ( result . jwtAuthGrant ) . toBe ( 'jag-token' ) ;
125152 } ) ;
126153
127154 it ( 'throws error when access_token is missing' , async ( ) => {
@@ -143,7 +170,7 @@ describe('crossAppAccess', () => {
143170 clientSecret : 'secret' ,
144171 fetchFn : mockFetch
145172 } )
146- ) . rejects . toThrow ( 'Missing or invalid access_token in token exchange response' ) ;
173+ ) . rejects . toThrow ( 'Invalid token exchange response' ) ;
147174 } ) ;
148175
149176 it ( 'handles OAuth error responses' , async ( ) => {
@@ -263,7 +290,7 @@ describe('crossAppAccess', () => {
263290 } ) ;
264291
265292 describe ( 'exchangeJwtAuthGrant' , ( ) => {
266- it ( 'successfully exchanges JWT Authorization Grant for access token' , async ( ) => {
293+ it ( 'exchanges JAG for access token using client_secret_basic by default ' , async ( ) => {
267294 const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
268295 ok : true ,
269296 json : async ( ) => ( {
@@ -292,13 +319,71 @@ describe('crossAppAccess', () => {
292319 expect ( url ) . toBe ( 'https://auth.chat.example/token' ) ;
293320 expect ( init ?. method ) . toBe ( 'POST' ) ;
294321
322+ // SEP-990 conformance: credentials in Authorization header, NOT in body
323+ const headers = new Headers ( init ?. headers as Headers ) ;
324+ const expectedCredentials = Buffer . from ( 'my-mcp-client:my-mcp-secret' ) . toString ( 'base64' ) ;
325+ expect ( headers . get ( 'Authorization' ) ) . toBe ( `Basic ${ expectedCredentials } ` ) ;
326+
295327 const body = new URLSearchParams ( init ?. body as string ) ;
296328 expect ( body . get ( 'grant_type' ) ) . toBe ( 'urn:ietf:params:oauth:grant-type:jwt-bearer' ) ;
297329 expect ( body . get ( 'assertion' ) ) . toBe ( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' ) ;
330+ expect ( body . has ( 'client_id' ) ) . toBe ( false ) ;
331+ expect ( body . has ( 'client_secret' ) ) . toBe ( false ) ;
332+ } ) ;
333+
334+ it ( 'supports client_secret_post when explicitly requested' , async ( ) => {
335+ const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
336+ ok : true ,
337+ json : async ( ) => ( {
338+ access_token : 'mcp-access-token' ,
339+ token_type : 'Bearer'
340+ } )
341+ } as Response ) ;
342+
343+ await exchangeJwtAuthGrant ( {
344+ tokenEndpoint : 'https://auth.chat.example/token' ,
345+ jwtAuthGrant : 'jwt' ,
346+ clientId : 'my-mcp-client' ,
347+ clientSecret : 'my-mcp-secret' ,
348+ authMethod : 'client_secret_post' ,
349+ fetchFn : mockFetch
350+ } ) ;
351+
352+ const [ , init ] = mockFetch . mock . calls [ 0 ] ! ;
353+ const headers = new Headers ( init ?. headers as Headers ) ;
354+ expect ( headers . get ( 'Authorization' ) ) . toBeNull ( ) ;
355+
356+ const body = new URLSearchParams ( init ?. body as string ) ;
298357 expect ( body . get ( 'client_id' ) ) . toBe ( 'my-mcp-client' ) ;
299358 expect ( body . get ( 'client_secret' ) ) . toBe ( 'my-mcp-secret' ) ;
300359 } ) ;
301360
361+ it ( 'supports authMethod none for public clients' , async ( ) => {
362+ const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
363+ ok : true ,
364+ json : async ( ) => ( {
365+ access_token : 'mcp-access-token' ,
366+ token_type : 'Bearer'
367+ } )
368+ } as Response ) ;
369+
370+ await exchangeJwtAuthGrant ( {
371+ tokenEndpoint : 'https://auth.chat.example/token' ,
372+ jwtAuthGrant : 'jwt' ,
373+ clientId : 'my-public-client' ,
374+ authMethod : 'none' ,
375+ fetchFn : mockFetch
376+ } ) ;
377+
378+ const [ , init ] = mockFetch . mock . calls [ 0 ] ! ;
379+ const headers = new Headers ( init ?. headers as Headers ) ;
380+ expect ( headers . get ( 'Authorization' ) ) . toBeNull ( ) ;
381+
382+ const body = new URLSearchParams ( init ?. body as string ) ;
383+ expect ( body . get ( 'client_id' ) ) . toBe ( 'my-public-client' ) ;
384+ expect ( body . has ( 'client_secret' ) ) . toBe ( false ) ;
385+ } ) ;
386+
302387 it ( 'handles OAuth error responses' , async ( ) => {
303388 const mockFetch = vi . fn < FetchLike > ( ) . mockResolvedValue ( {
304389 ok : false ,
0 commit comments