@@ -239,8 +239,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
239239 mockResponse : map [string ]any {
240240 "active" : false ,
241241 },
242- mockStatus : http .StatusOK ,
243- wantError : true ,
242+ mockStatus : http .StatusOK ,
243+ wantError : true ,
244244 errContains : "token is not active" ,
245245 },
246246 {
@@ -252,21 +252,21 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
252252 "scope" : "read:files" ,
253253 "exp" : time .Now ().Add (time .Hour ).Unix (),
254254 },
255- mockStatus : http .StatusOK ,
256- wantError : true ,
255+ mockStatus : http .StatusOK ,
256+ wantError : true ,
257257 errContains : "insufficient scopes" ,
258258 },
259259 {
260- name : "audience mismatch" ,
261- token : "opaque-bad-aud" ,
262- audience : "my-audience" ,
260+ name : "audience mismatch" ,
261+ token : "opaque-bad-aud" ,
262+ audience : "my-audience" ,
263263 mockResponse : map [string ]any {
264264 "active" : true ,
265265 "client_id" : "wrong-audience" ,
266266 "exp" : time .Now ().Add (time .Hour ).Unix (),
267267 },
268- mockStatus : http .StatusOK ,
269- wantError : true ,
268+ mockStatus : http .StatusOK ,
269+ wantError : true ,
270270 errContains : "audience validation failed" ,
271271 },
272272 {
@@ -276,8 +276,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
276276 "active" : true ,
277277 "exp" : time .Now ().Add (- 1 * time .Hour ).Unix (),
278278 },
279- mockStatus : http .StatusOK ,
280- wantError : true ,
279+ mockStatus : http .StatusOK ,
280+ wantError : true ,
281281 errContains : "token has expired" ,
282282 },
283283 {
@@ -286,8 +286,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
286286 mockResponse : map [string ]any {
287287 "error" : "server_error" ,
288288 },
289- mockStatus : http .StatusInternalServerError ,
290- wantError : true ,
289+ mockStatus : http .StatusInternalServerError ,
290+ wantError : true ,
291291 errContains : "introspection failed with status: 500" ,
292292 },
293293 }
@@ -361,3 +361,256 @@ func TestValidateMCPAuth_Opaque(t *testing.T) {
361361 }
362362}
363363
364+ func TestValidateJwtToken (t * testing.T ) {
365+ privateKey := generateRSAPrivateKey (t )
366+ keyID := "test-key-id"
367+ server := setupJWKSMockServer (t , privateKey , keyID )
368+ defer server .Close ()
369+
370+ cfg := Config {
371+ Name : "test-generic-auth" ,
372+ Type : "generic" ,
373+ Audience : "my-audience" ,
374+ AuthorizationServer : server .URL ,
375+ ScopesRequired : []string {"read:files" },
376+ }
377+
378+ authService , err := cfg .Initialize ()
379+ if err != nil {
380+ t .Fatalf ("failed to initialize auth service: %v" , err )
381+ }
382+
383+ genericAuth , ok := authService .(* AuthService )
384+ if ! ok {
385+ t .Fatalf ("expected *AuthService, got %T" , authService )
386+ }
387+
388+ tests := []struct {
389+ name string
390+ token string
391+ wantError bool
392+ errContains string
393+ }{
394+ {
395+ name : "valid jwt" ,
396+ token : generateValidToken (t , privateKey , keyID , jwt.MapClaims {
397+ "aud" : "my-audience" ,
398+ "scope" : "read:files" ,
399+ "exp" : time .Now ().Add (time .Hour ).Unix (),
400+ }),
401+ wantError : false ,
402+ },
403+ {
404+ name : "invalid token (wrong signature)" ,
405+ token : "header.payload.signature" ,
406+ wantError : true ,
407+ errContains : "invalid or expired token" ,
408+ },
409+ {
410+ name : "audience mismatch" ,
411+ token : generateValidToken (t , privateKey , keyID , jwt.MapClaims {
412+ "aud" : "wrong-audience" ,
413+ "scope" : "read:files" ,
414+ "exp" : time .Now ().Add (time .Hour ).Unix (),
415+ }),
416+ wantError : true ,
417+ errContains : "audience validation failed" ,
418+ },
419+ {
420+ name : "insufficient scopes" ,
421+ token : generateValidToken (t , privateKey , keyID , jwt.MapClaims {
422+ "aud" : "my-audience" ,
423+ "scope" : "wrong:scope" ,
424+ "exp" : time .Now ().Add (time .Hour ).Unix (),
425+ }),
426+ wantError : true ,
427+ errContains : "insufficient scopes" ,
428+ },
429+ }
430+
431+ for _ , tc := range tests {
432+ t .Run (tc .name , func (t * testing.T ) {
433+ err := genericAuth .validateJwtToken (context .Background (), tc .token )
434+ if tc .wantError {
435+ if err == nil {
436+ t .Fatalf ("expected error, got nil" )
437+ }
438+ if tc .errContains != "" && ! strings .Contains (err .Error (), tc .errContains ) {
439+ t .Errorf ("expected error containing %q, got: %v" , tc .errContains , err )
440+ }
441+ } else {
442+ if err != nil {
443+ t .Fatalf ("unexpected error: %v" , err )
444+ }
445+ }
446+ })
447+ }
448+ }
449+
450+ func TestValidateOpaqueToken (t * testing.T ) {
451+ tests := []struct {
452+ name string
453+ token string
454+ scopesRequired []string
455+ audience string
456+ mockResponse map [string ]any
457+ mockStatus int
458+ wantError bool
459+ errContains string
460+ }{
461+ {
462+ name : "valid opaque token" ,
463+ token : "opaque-valid" ,
464+ scopesRequired : []string {"read:files" },
465+ audience : "my-audience" ,
466+ mockResponse : map [string ]any {
467+ "active" : true ,
468+ "scope" : "read:files write:files" ,
469+ "client_id" : "my-audience" ,
470+ "exp" : time .Now ().Add (time .Hour ).Unix (),
471+ },
472+ mockStatus : http .StatusOK ,
473+ wantError : false ,
474+ },
475+ {
476+ name : "inactive opaque token" ,
477+ token : "opaque-inactive" ,
478+ scopesRequired : []string {"read:files" },
479+ mockResponse : map [string ]any {
480+ "active" : false ,
481+ },
482+ mockStatus : http .StatusOK ,
483+ wantError : true ,
484+ errContains : "token is not active" ,
485+ },
486+ {
487+ name : "insufficient scopes" ,
488+ token : "opaque-bad-scope" ,
489+ scopesRequired : []string {"read:files" , "write:files" },
490+ mockResponse : map [string ]any {
491+ "active" : true ,
492+ "scope" : "read:files" ,
493+ "exp" : time .Now ().Add (time .Hour ).Unix (),
494+ },
495+ mockStatus : http .StatusOK ,
496+ wantError : true ,
497+ errContains : "insufficient scopes" ,
498+ },
499+ {
500+ name : "audience mismatch" ,
501+ token : "opaque-bad-aud" ,
502+ audience : "my-audience" ,
503+ mockResponse : map [string ]any {
504+ "active" : true ,
505+ "client_id" : "wrong-audience" ,
506+ "exp" : time .Now ().Add (time .Hour ).Unix (),
507+ },
508+ mockStatus : http .StatusOK ,
509+ wantError : true ,
510+ errContains : "audience validation failed" ,
511+ },
512+ {
513+ name : "expired token" ,
514+ token : "opaque-expired" ,
515+ mockResponse : map [string ]any {
516+ "active" : true ,
517+ "exp" : time .Now ().Add (- 1 * time .Hour ).Unix (),
518+ },
519+ mockStatus : http .StatusOK ,
520+ wantError : true ,
521+ errContains : "token has expired" ,
522+ },
523+ {
524+ name : "introspection error status" ,
525+ token : "opaque-error" ,
526+ mockResponse : map [string ]any {
527+ "error" : "server_error" ,
528+ },
529+ mockStatus : http .StatusInternalServerError ,
530+ wantError : true ,
531+ errContains : "introspection failed with status: 500" ,
532+ },
533+ }
534+
535+ for _ , tc := range tests {
536+ t .Run (tc .name , func (t * testing.T ) {
537+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
538+ if r .URL .Path == "/introspect" {
539+ w .Header ().Set ("Content-Type" , "application/json" )
540+ w .WriteHeader (tc .mockStatus )
541+ _ = json .NewEncoder (w ).Encode (tc .mockResponse )
542+ return
543+ }
544+ http .NotFound (w , r )
545+ })
546+ server := httptest .NewServer (handler )
547+ defer server .Close ()
548+
549+ genericAuth := & AuthService {
550+ Config : Config {
551+ Audience : tc .audience ,
552+ AuthorizationServer : server .URL ,
553+ ScopesRequired : tc .scopesRequired ,
554+ },
555+ }
556+
557+ err := genericAuth .validateOpaqueToken (context .Background (), tc .token )
558+
559+ if tc .wantError {
560+ if err == nil {
561+ t .Fatalf ("expected error, got nil" )
562+ }
563+ if tc .errContains != "" && ! strings .Contains (err .Error (), tc .errContains ) {
564+ t .Errorf ("expected error containing %q, got: %v" , tc .errContains , err )
565+ }
566+ } else {
567+ if err != nil {
568+ t .Fatalf ("unexpected error: %v" , err )
569+ }
570+ }
571+ })
572+ }
573+ }
574+
575+ func TestIsJWTFormat (t * testing.T ) {
576+ tests := []struct {
577+ name string
578+ token string
579+ want bool
580+ }{
581+ {
582+ name : "valid JWT format" ,
583+ token : "header.payload.signature" ,
584+ want : true ,
585+ },
586+ {
587+ name : "opaque token" ,
588+ token : "opaque-token" ,
589+ want : false ,
590+ },
591+ {
592+ name : "too many dots" ,
593+ token : "a.b.c.d" ,
594+ want : false ,
595+ },
596+ {
597+ name : "too few dots" ,
598+ token : "a.b" ,
599+ want : false ,
600+ },
601+ {
602+ name : "empty string" ,
603+ token : "" ,
604+ want : false ,
605+ },
606+ }
607+
608+ for _ , tc := range tests {
609+ t .Run (tc .name , func (t * testing.T ) {
610+ got := isJWTFormat (tc .token )
611+ if got != tc .want {
612+ t .Errorf ("isJWTFormat(%q) = %v; want %v" , tc .token , got , tc .want )
613+ }
614+ })
615+ }
616+ }
0 commit comments