Skip to content

Commit eda39a7

Browse files
committed
fixes from review
1 parent ecb6827 commit eda39a7

6 files changed

Lines changed: 77 additions & 16 deletions

File tree

pkg/vmcp/server/session_management_v2_integration_test.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package server_test
66
import (
77
"bytes"
88
"context"
9+
"encoding/hex"
910
"encoding/json"
1011
"errors"
1112
"io"
@@ -105,25 +106,53 @@ func newV2FakeFactory(tools []vmcp.Tool) *v2FakeMultiSessionFactory {
105106
}
106107

107108
func (f *v2FakeMultiSessionFactory) MakeSession(
108-
_ context.Context, _ *auth.Identity, _ []*vmcp.Backend,
109+
_ context.Context, identity *auth.Identity, _ []*vmcp.Backend,
109110
) (vmcpsession.MultiSession, error) {
110111
if f.err != nil {
111112
return nil, f.err
112113
}
113114
baseSession := transportsession.NewStreamableSession("auto-id")
115+
116+
// Populate token hash metadata to match real session factory behavior.
117+
allowAnonymous := vmcpsession.ShouldAllowAnonymous(identity)
118+
if identity != nil && identity.Token != "" && !allowAnonymous {
119+
testSecret := []byte("integration-test-secret")
120+
testSalt := []byte("test-salt-123456")
121+
tokenHash := vmcpsession.HashToken(identity.Token, testSecret, testSalt)
122+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenHash, tokenHash)
123+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenSalt, hex.EncodeToString(testSalt))
124+
} else {
125+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenHash, "")
126+
}
127+
114128
sess := newV2FakeMultiSession(baseSession, f.tools)
115129
f.lastCreatedSession = sess
116130
return sess, nil
117131
}
118132

119133
func (f *v2FakeMultiSessionFactory) MakeSessionWithID(
120-
_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend,
134+
_ context.Context, id string, identity *auth.Identity, allowAnonymous bool, _ []*vmcp.Backend,
121135
) (vmcpsession.MultiSession, error) {
122136
f.makeWithIDCalled.Store(true)
123137
if f.err != nil {
124138
return nil, f.err
125139
}
126140
baseSession := transportsession.NewStreamableSession(id)
141+
142+
// Populate token hash metadata to match real session factory behavior.
143+
// This allows integration tests to verify that hashes (not raw tokens) are stored.
144+
if identity != nil && identity.Token != "" && !allowAnonymous {
145+
// Use a test HMAC secret and salt for integration tests
146+
testSecret := []byte("integration-test-secret")
147+
testSalt := []byte("test-salt-123456") // 16 bytes
148+
tokenHash := vmcpsession.HashToken(identity.Token, testSecret, testSalt)
149+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenHash, tokenHash)
150+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenSalt, hex.EncodeToString(testSalt))
151+
} else {
152+
// Anonymous session
153+
baseSession.SetMetadata(vmcpsession.MetadataKeyTokenHash, "")
154+
}
155+
127156
sess := newV2FakeMultiSession(baseSession, f.tools)
128157
f.lastCreatedSession = sess
129158
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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
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"
10+
"github.com/stacklok/toolhive/pkg/vmcp/session/internal/security"
911
sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types"
1012
)
1113

@@ -58,3 +60,43 @@ type MultiSession interface {
5860
// sessions for debugging and auditing.
5961
BackendSessions() map[string]string
6062
}
63+
64+
const (
65+
// MetadataKeyTokenHash is the session metadata key that holds the HMAC-SHA256
66+
// hash of the bearer token used to create the session. For authenticated sessions
67+
// this is hex(HMAC-SHA256(bearerToken)). For anonymous sessions this is the empty
68+
// string sentinel. The raw token is never stored — only the hash.
69+
MetadataKeyTokenHash = security.MetadataKeyTokenHash
70+
71+
// MetadataKeyTokenSalt is the session metadata key that holds the hex-encoded
72+
// random salt used for HMAC-SHA256 token hashing. Each session has a unique salt
73+
// to prevent attacks across multiple sessions.
74+
MetadataKeyTokenSalt = security.MetadataKeyTokenSalt
75+
)
76+
77+
// ShouldAllowAnonymous determines if a session should allow anonymous access
78+
// based on the creator's identity. Sessions without an identity (nil) or with
79+
// an empty token are anonymous; sessions with a non-empty bearer token are
80+
// bound to that token.
81+
//
82+
// This function consolidates the anonymous session logic and aligns with the
83+
// validation logic in session hijacking prevention.
84+
func ShouldAllowAnonymous(identity *auth.Identity) bool {
85+
return identity == nil || identity.Token == ""
86+
}
87+
88+
// HashToken returns the hex-encoded HMAC-SHA256 hash of a raw bearer token string.
89+
// Uses HMAC with a server-managed secret and per-session salt to prevent offline
90+
// attacks if session storage is compromised.
91+
//
92+
// For empty tokens (anonymous sessions) it returns the empty string, which is
93+
// the sentinel value used to identify sessions created without credentials.
94+
// The raw token is never stored — only the hash.
95+
//
96+
// Parameters:
97+
// - token: The bearer token to hash
98+
// - secret: Server-managed HMAC secret (should be 32+ bytes)
99+
// - salt: Per-session random salt (typically 16 bytes)
100+
func HashToken(token string, secret, salt []byte) string {
101+
return security.HashToken(token, secret, salt)
102+
}

0 commit comments

Comments
 (0)