@@ -105,25 +105,50 @@ func newV2FakeFactory(tools []vmcp.Tool) *v2FakeMultiSessionFactory {
105105}
106106
107107func (f * v2FakeMultiSessionFactory ) MakeSession (
108- _ context.Context , _ * auth.Identity , _ []* vmcp.Backend ,
108+ _ context.Context , identity * auth.Identity , _ []* vmcp.Backend ,
109109) (vmcpsession.MultiSession , error ) {
110110 if f .err != nil {
111111 return nil , f .err
112112 }
113113 baseSession := transportsession .NewStreamableSession ("auto-id" )
114+
115+ // Set basic metadata to indicate whether this is an anonymous session.
116+ // Integration tests don't need to verify crypto implementation details.
117+ allowAnonymous := vmcpsession .ShouldAllowAnonymous (identity )
118+ if ! allowAnonymous {
119+ // Authenticated session - set non-empty hash placeholder
120+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenHash , "fake-hash-for-testing" )
121+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenSalt , "fake-salt-for-testing" )
122+ } else {
123+ // Anonymous session - set empty hash
124+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenHash , "" )
125+ }
126+
114127 sess := newV2FakeMultiSession (baseSession , f .tools )
115128 f .lastCreatedSession = sess
116129 return sess , nil
117130}
118131
119132func (f * v2FakeMultiSessionFactory ) MakeSessionWithID (
120- _ context.Context , id string , _ * auth.Identity , _ bool , _ []* vmcp.Backend ,
133+ _ context.Context , id string , identity * auth.Identity , allowAnonymous bool , _ []* vmcp.Backend ,
121134) (vmcpsession.MultiSession , error ) {
122135 f .makeWithIDCalled .Store (true )
123136 if f .err != nil {
124137 return nil , f .err
125138 }
126139 baseSession := transportsession .NewStreamableSession (id )
140+
141+ // Set basic metadata to indicate whether this is an anonymous session.
142+ // Integration tests don't need to verify crypto implementation details.
143+ if identity != nil && identity .Token != "" && ! allowAnonymous {
144+ // Authenticated session - set non-empty hash placeholder
145+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenHash , "fake-hash-for-testing" )
146+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenSalt , "fake-salt-for-testing" )
147+ } else {
148+ // Anonymous session - set empty hash
149+ baseSession .SetMetadata (vmcpsession .MetadataKeyTokenHash , "" )
150+ }
151+
127152 sess := newV2FakeMultiSession (baseSession , f .tools )
128153 f .lastCreatedSession = sess
129154 return sess , nil
@@ -444,3 +469,168 @@ func TestIntegration_SessionManagementV2_OldPathUnused(t *testing.T) {
444469 "MakeSessionWithID should NOT be called when SessionManagementV2 is false" ,
445470 )
446471}
472+
473+ // TestIntegration_SessionManagementV2_TokenBinding verifies end-to-end token binding security:
474+ //
475+ // 1. Initialize a session with bearer token "token-A"
476+ // 2. Make a tool call with the same token → succeeds
477+ // 3. Make a tool call with a different token "token-B" → fails with unauthorized
478+ // 4. Verify the session is terminated after auth failure
479+ //
480+ // NOTE: This test is currently skipped because the fake factory (v2FakeMultiSessionFactory)
481+ // doesn't implement real token binding - it uses placeholder metadata instead of real
482+ // HMAC-SHA256 hashes. To properly test token binding end-to-end, this test would need
483+ // to use the real defaultMultiSessionFactory with a real HMAC secret.
484+ //
485+ // Token binding security is comprehensively tested at the unit level in:
486+ // - pkg/vmcp/session/token_binding_test.go (factory behavior)
487+ // - pkg/vmcp/session/internal/security/*_test.go (crypto and validation)
488+ // - pkg/vmcp/server/sessionmanager/session_manager_test.go (termination on auth errors)
489+ //
490+ // TODO: Refactor test infrastructure to support real session factory for security tests.
491+ func TestIntegration_SessionManagementV2_TokenBinding (t * testing.T ) {
492+ t .Skip ("Fake factory doesn't implement real token binding - see test comment for details" )
493+ t .Parallel ()
494+
495+ testTool := vmcp.Tool {Name : "echo" , Description : "echoes input" }
496+ factory := newV2FakeFactory ([]vmcp.Tool {testTool })
497+ ts := buildV2Server (t , factory )
498+
499+ tokenA := "bearer-token-A"
500+ tokenB := "bearer-token-B"
501+
502+ // Step 1: Initialize with token A
503+ initReq := map [string ]any {
504+ "jsonrpc" : "2.0" ,
505+ "id" : 1 ,
506+ "method" : "initialize" ,
507+ "params" : map [string ]any {
508+ "protocolVersion" : "2025-06-18" ,
509+ "capabilities" : map [string ]any {},
510+ "clientInfo" : map [string ]any {
511+ "name" : "test-client" ,
512+ "version" : "1.0" ,
513+ },
514+ },
515+ }
516+
517+ req , err := http .NewRequestWithContext (context .Background (), http .MethodPost , ts .URL + "/mcp" , nil )
518+ require .NoError (t , err )
519+ req .Header .Set ("Content-Type" , "application/json" )
520+ req .Header .Set ("Authorization" , "Bearer " + tokenA ) // Set token A
521+
522+ reqBody , err := json .Marshal (initReq )
523+ require .NoError (t , err )
524+ req .Body = io .NopCloser (bytes .NewReader (reqBody ))
525+
526+ initResp , err := http .DefaultClient .Do (req )
527+ require .NoError (t , err )
528+ defer initResp .Body .Close ()
529+
530+ require .Equal (t , http .StatusOK , initResp .StatusCode )
531+ sessionID := initResp .Header .Get ("Mcp-Session-Id" )
532+ require .NotEmpty (t , sessionID , "should receive session ID" )
533+
534+ // Wait for factory to be called
535+ require .Eventually (t ,
536+ func () bool { return factory .makeWithIDCalled .Load () },
537+ 1 * time .Second ,
538+ 10 * time .Millisecond ,
539+ "factory should be called to create session" ,
540+ )
541+
542+ // Step 2: Call tool with token A (same as initialization) → should succeed
543+ toolReqA := map [string ]any {
544+ "jsonrpc" : "2.0" ,
545+ "id" : 2 ,
546+ "method" : "tools/call" ,
547+ "params" : map [string ]any {
548+ "name" : "echo" ,
549+ "arguments" : map [string ]any {"msg" : "hello" },
550+ },
551+ }
552+
553+ reqA , err := http .NewRequestWithContext (context .Background (), http .MethodPost , ts .URL + "/mcp" , nil )
554+ require .NoError (t , err )
555+ reqA .Header .Set ("Content-Type" , "application/json" )
556+ reqA .Header .Set ("Mcp-Session-Id" , sessionID )
557+ reqA .Header .Set ("Authorization" , "Bearer " + tokenA ) // Same token
558+
559+ reqBodyA , err := json .Marshal (toolReqA )
560+ require .NoError (t , err )
561+ reqA .Body = io .NopCloser (bytes .NewReader (reqBodyA ))
562+
563+ respA , err := http .DefaultClient .Do (reqA )
564+ require .NoError (t , err )
565+ defer respA .Body .Close ()
566+
567+ assert .Equal (t , http .StatusOK , respA .StatusCode , "tool call with matching token should succeed" )
568+
569+ // Step 3: Call tool with token B (different from initialization) → should fail
570+ toolReqB := map [string ]any {
571+ "jsonrpc" : "2.0" ,
572+ "id" : 3 ,
573+ "method" : "tools/call" ,
574+ "params" : map [string ]any {
575+ "name" : "echo" ,
576+ "arguments" : map [string ]any {"msg" : "hijack attempt" },
577+ },
578+ }
579+
580+ reqB , err := http .NewRequestWithContext (context .Background (), http .MethodPost , ts .URL + "/mcp" , nil )
581+ require .NoError (t , err )
582+ reqB .Header .Set ("Content-Type" , "application/json" )
583+ reqB .Header .Set ("Mcp-Session-Id" , sessionID )
584+ reqB .Header .Set ("Authorization" , "Bearer " + tokenB ) // Different token!
585+
586+ reqBodyB , err := json .Marshal (toolReqB )
587+ require .NoError (t , err )
588+ reqB .Body = io .NopCloser (bytes .NewReader (reqBodyB ))
589+
590+ respB , err := http .DefaultClient .Do (reqB )
591+ require .NoError (t , err )
592+ defer respB .Body .Close ()
593+
594+ // The request should succeed at HTTP level but return an error result
595+ require .Equal (t , http .StatusOK , respB .StatusCode , "HTTP request should succeed" )
596+
597+ var result map [string ]any
598+ err = json .NewDecoder (respB .Body ).Decode (& result )
599+ require .NoError (t , err )
600+
601+ // Should contain an error about unauthorized
602+ resultMap , ok := result ["result" ].(map [string ]any )
603+ require .True (t , ok , "result should be an object" )
604+
605+ isError , ok := resultMap ["isError" ].(bool )
606+ require .True (t , ok && isError , "result should indicate error" )
607+
608+ // Step 4: Verify session is terminated (subsequent requests should fail)
609+ toolReqC := map [string ]any {
610+ "jsonrpc" : "2.0" ,
611+ "id" : 4 ,
612+ "method" : "tools/call" ,
613+ "params" : map [string ]any {
614+ "name" : "echo" ,
615+ "arguments" : map [string ]any {"msg" : "after termination" },
616+ },
617+ }
618+
619+ reqC , err := http .NewRequestWithContext (context .Background (), http .MethodPost , ts .URL + "/mcp" , nil )
620+ require .NoError (t , err )
621+ reqC .Header .Set ("Content-Type" , "application/json" )
622+ reqC .Header .Set ("Mcp-Session-Id" , sessionID )
623+ reqC .Header .Set ("Authorization" , "Bearer " + tokenA ) // Even with original token
624+
625+ reqBodyC , err := json .Marshal (toolReqC )
626+ require .NoError (t , err )
627+ reqC .Body = io .NopCloser (bytes .NewReader (reqBodyC ))
628+
629+ respC , err := http .DefaultClient .Do (reqC )
630+ require .NoError (t , err )
631+ defer respC .Body .Close ()
632+
633+ // Session should be terminated, so this should fail
634+ assert .Equal (t , http .StatusInternalServerError , respC .StatusCode ,
635+ "request should fail after session termination due to auth failure" )
636+ }
0 commit comments