Skip to content

Commit 72f8d78

Browse files
committed
fixes from review
1 parent ecb6827 commit 72f8d78

6 files changed

Lines changed: 67 additions & 16 deletions

File tree

pkg/vmcp/server/session_management_v2_integration_test.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,25 +105,50 @@ func newV2FakeFactory(tools []vmcp.Tool) *v2FakeMultiSessionFactory {
105105
}
106106

107107
func (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

119132
func (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

pkg/vmcp/server/sessionmanager/session_manager.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func (sm *Manager) CreateSession(
165165
// Build the fully-formed MultiSession using the SDK-assigned session ID.
166166
// Sessions created with an identity are bound to that identity (allowAnonymous=false).
167167
// Sessions created without an identity allow anonymous access (allowAnonymous=true).
168-
allowAnonymous := identity == nil || identity.Token == ""
168+
allowAnonymous := vmcpsession.ShouldAllowAnonymous(identity)
169169
sess, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, allowAnonymous, backends)
170170
if err != nil {
171171
return nil, fmt.Errorf("Manager.CreateSession: failed to create multi-session: %w", err)

pkg/vmcp/session/factory.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ func (f *defaultMultiSessionFactory) MakeSession(
270270
) (MultiSession, error) {
271271
// Sessions created with an identity are bound to that identity (allowAnonymous=false).
272272
// Sessions created without an identity allow anonymous access (allowAnonymous=true).
273-
allowAnonymous := security.ShouldAllowAnonymous(identity)
273+
allowAnonymous := ShouldAllowAnonymous(identity)
274274
return f.makeSession(ctx, uuid.New().String(), identity, allowAnonymous, backends)
275275
}
276276

pkg/vmcp/session/internal/security/security.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,6 @@ const (
4040
MetadataKeyTokenSalt = "vmcp.token.salt" //nolint:gosec // This is a metadata key name, not a credential.
4141
)
4242

43-
// ShouldAllowAnonymous determines if a session should allow anonymous access
44-
// based on the creator's identity. Sessions without an identity (nil) or with
45-
// an empty token are anonymous; sessions with a non-empty bearer token are
46-
// bound to that token.
47-
//
48-
// This helper consolidates the anonymous session logic and aligns with the
49-
// validation logic in PreventSessionHijacking.
50-
func ShouldAllowAnonymous(identity *auth.Identity) bool {
51-
return identity == nil || identity.Token == ""
52-
}
53-
5443
// GenerateSalt generates a cryptographically secure random salt for token hashing.
5544
// Returns 16 bytes of random data from crypto/rand.
5645
//

pkg/vmcp/session/internal/security/security_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/stretchr/testify/require"
1313

1414
"github.com/stacklok/toolhive/pkg/auth"
15+
"github.com/stacklok/toolhive/pkg/vmcp/session"
1516
"github.com/stacklok/toolhive/pkg/vmcp/session/internal/security"
1617
)
1718

@@ -306,7 +307,7 @@ func TestShouldAllowAnonymous_EdgeCases(t *testing.T) {
306307
for _, tt := range tests {
307308
t.Run(tt.name, func(t *testing.T) {
308309
t.Parallel()
309-
got := security.ShouldAllowAnonymous(tt.identity)
310+
got := session.ShouldAllowAnonymous(tt.identity)
310311
assert.Equal(t, tt.want, got)
311312
})
312313
}

pkg/vmcp/session/session.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package session
55

66
import (
7+
"github.com/stacklok/toolhive/pkg/auth"
78
transportsession "github.com/stacklok/toolhive/pkg/transport/session"
89
"github.com/stacklok/toolhive/pkg/vmcp"
910
sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types"
@@ -58,3 +59,38 @@ type MultiSession interface {
5859
// sessions for debugging and auditing.
5960
BackendSessions() map[string]string
6061
}
62+
63+
const (
64+
// MetadataKeyTokenHash is the session metadata key that holds the HMAC-SHA256
65+
// hash of the bearer token used to create the session. For authenticated sessions
66+
// this is hex(HMAC-SHA256(bearerToken)). For anonymous sessions this is the empty
67+
// string sentinel. The raw token is never stored — only the hash.
68+
//
69+
// This constant is used by the session factory and security layer to store and
70+
// validate token binding metadata.
71+
MetadataKeyTokenHash = "vmcp.token.hash" //nolint:gosec // This is a metadata key name, not a credential.
72+
73+
// MetadataKeyTokenSalt is the session metadata key that holds the hex-encoded
74+
// random salt used for HMAC-SHA256 token hashing. Each session has a unique salt
75+
// to prevent attacks across multiple sessions.
76+
//
77+
// This constant is used by the session factory and security layer to store and
78+
// validate token binding metadata.
79+
MetadataKeyTokenSalt = "vmcp.token.salt" //nolint:gosec // This is a metadata key name, not a credential.
80+
)
81+
82+
// ShouldAllowAnonymous determines if a session should allow anonymous access
83+
// based on the creator's identity. This is session business logic that decides
84+
// whether a session is bound to a specific identity or allows anonymous access.
85+
//
86+
// Sessions without an identity (nil) or with an empty token are treated as
87+
// anonymous and will accept requests from any caller. Sessions with a non-empty
88+
// bearer token are bound to that token and will reject requests from different
89+
// callers.
90+
//
91+
// This function is used by both the session factory (to determine how to create
92+
// the session) and the security layer (to validate requests against the session's
93+
// access policy).
94+
func ShouldAllowAnonymous(identity *auth.Identity) bool {
95+
return identity == nil || identity.Token == ""
96+
}

0 commit comments

Comments
 (0)