Skip to content

Commit bedd6ba

Browse files
jhrozekclaude
andauthored
Bind vMCP sessions to OIDC identity, not raw token bytes (#5378)
* Add pkg/vmcp/session/binding leaf package The vMCP session-binding format needs to be parsed and produced in two places: pkg/vmcp/session/types (from ShouldAllowAnonymous) and pkg/vmcp/session/internal/security (from BindSession / validateCaller). A private helper in each would drift over time, so this commit introduces a single-owner leaf package. Format encodes a bound identity as iss + "\x00" + sub, rejecting empty halves and stray NULs. Parse mirrors Format strictly — including rejecting trailing NULs in the sub half so values that did not pass through Format (e.g. direct writes to Redis) fail loudly. A literal "unauthenticated" sentinel covers sessions created without an authenticated identity. No callers wired yet — those land in the next commits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add MetadataKeyIdentityBinding key and tighten ShouldAllowAnonymous The hijack-prevention decorator needs a stable per-identity key in session metadata. Add MetadataKeyIdentityBinding alongside the existing token-hash keys (the legacy keys stay temporarily so already-running sessions can be invalidated cleanly during the migration; final removal happens in the operator-side follow-up). ShouldAllowAnonymous treated every empty-token identity as anonymous, which lumped LocalUserMiddleware identities into the same equivalence class even when they carried distinct (iss, sub) claims — letting one local user reuse another's session ID. Tighten the rule so any identity with a valid (iss, sub) pair from Claims goes through the bound path, even when Token is empty. Pull iss and sub from Identity.Claims rather than Identity.Subject so the introspection path and the JWT path canonicalize against the same source. Fail-closed on non-string iss/sub claims: a misbehaving validator that stores a numeric or array value is treated as bound (not anonymous), with a WARN logged so the misbehavior surfaces to operators. The new key is unused until the security and factory layers are wired in the next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Bind sessions to (iss, sub), not raw token bytes The hijack-prevention decorator hashed the incoming bearer token at session creation and rejected any subsequent request whose token hashed differently. Every legitimate OAuth refresh produces a new access token with different bytes — same identity, same iss, same sub — so the decorator misclassified the refresh as a hijack and terminated the session with Unauthorized: caller identity does not match session owner. Drop the HMAC plumbing entirely and bind to a stable (iss, sub) tuple extracted from the OIDC identity's Claims. The binding lives in session metadata under MetadataKeyIdentityBinding, written exclusively by the new BindSession constructor (renamed from PreventSessionHijacking). Validation reads the caller's claims through the same path so the JWT and introspection code paths canonicalize against the same source. The session-upgrade defense (anonymous session, caller presents a token) moves to the unauthenticated-sentinel branch and works exactly as before. RestoreSession reconstructs identity from the stored binding rather than from a separate identity-subject key. Sessions written under the legacy token-hash schema return the bare transportsession.ErrSessionNotFound sentinel so the client receives the standard "re-initialise" signal one forced re-auth at deploy is preferable to a rebind-on-first-use window that would re-introduce the hijack the check exists to block. Downstream callers of the removed WithHMACSecret option (cli/serve.go) and the Phase-2 marker switch (sessionmanager) follow in the next commits; tests catch up after that. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Use MetadataKeyIdentityBinding as the Phase-2 marker BindSession writes MetadataKeyIdentityBinding on every successful session creation (sentinel for anonymous, real binding for authenticated), so its presence is the new way to distinguish a fully-initialised Phase-2 session from a Generate()-only placeholder. Without this swap, the now-unwritten MetadataKeyTokenHash would always read as absent and Terminate would take the placeholder path on every session, breaking termination semantics. The behaviour for legacy sessions still in Redis from before the migration is intentional and documented in the plan: Terminate writes the placeholder-terminated marker (Update with MetadataKeyTerminated) which sits for the TTL; loadSession returns transportsession.ErrSessionNotFound so the client transparently re-initialises. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Drop HMAC plumbing from serve.go; document Redis trust boundary createSessionFactory no longer takes an HMAC secret or a Kubernetes detection flag. VMCP_SESSION_HMAC_SECRET stays readable from the environment for one deploy cycle (DEBUG-logged and ignored) so the operator-side env-var injection can be removed in a follow-up PR without forcing a coordinated cut-over. Add a startup WARN when incoming auth is configured as "anonymous": AnonymousMiddleware populates the same (iss, sub) for every request, so all callers collide on one identity binding and per-identity hijack prevention degrades to dev-only behaviour. Surface that to operators rather than letting them assume the binding still scopes per user. Document the trust boundary in docs/arch/13-vmcp-scalability.md: the new scheme stores plaintext (iss, sub) at rest in Redis/Valkey, which trades the HMAC's at-rest opacity for refresh correctness. Operators must layer Redis ACLs or NetworkPolicies if a Redis dump revealing identity is unacceptable. Add a TODO on extractBindingID for the forward-looking RFC 7662 case (probe IdP introspection responses for iss + sub) so it surfaces if that becomes a top-level incoming-auth type. The five HMAC-specific serve_test.go cases collapse to one table-driven test of the new signature; the removed cases targeted HMAC validation that no longer exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent db82aef commit bedd6ba

27 files changed

Lines changed: 1713 additions & 1405 deletions

docs/arch/13-vmcp-scalability.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@ resets the key's TTL atomically (see `pkg/transport/session/storage_redis.go`,
8383
| Inactivity beyond TTL | Redis TTL expiry (automatic, no application-side action needed) |
8484
| Pod-local cache eviction (LRU) | `onEvict` callback closes backend connections only; the Redis metadata key is **not** deleted and expires via TTL |
8585

86+
### Identity-binding storage and Redis access control
87+
88+
Each vMCP session carries an identity binding stored in session metadata under the
89+
key `vmcp.identity.binding`. The canonical format is defined in
90+
`pkg/vmcp/session/binding/binding.go`: a NUL-separated `iss + "\x00" + sub` for
91+
authenticated sessions, and the literal string `"unauthenticated"` for sessions
92+
without an auth identity.
93+
94+
The binding is stored as **plaintext** in the session store (Redis/Valkey). It is
95+
not a credential — it identifies but does not authenticate a principal — but it is
96+
personally-identifying information (a combination of issuer URL and user subject).
97+
98+
Operators are responsible for access-controlling the Redis/Valkey instance
99+
equivalently to any other identity store. Concretely: enable Redis ACLs (Redis 6+)
100+
or `requirepass`, restrict network reach with a Kubernetes `NetworkPolicy`, and
101+
avoid sharing the cluster with untrusted workloads.
102+
103+
The session store prior to issue #5306 held an HMAC of the bearer token rather than
104+
the raw `(iss, sub)` pair. That scheme reduced the value of a Redis dump at the cost
105+
of breaking on every legitimate OAuth token refresh. The current scheme accepts
106+
plaintext PII at rest as the price of correctness; operators who require additional
107+
protection against a Redis compromise must layer Redis-side access controls as
108+
described above.
109+
86110
## File descriptor limits
87111

88112
Each open backend connection consumes one file descriptor on the vMCP pod. A

pkg/vmcp/cli/serve.go

Lines changed: 19 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,15 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
252252

253253
slog.Info(fmt.Sprintf("Setting up incoming authentication (type: %s)", vmcpCfg.IncomingAuth.Type))
254254

255+
if vmcpCfg.IncomingAuth.Type == config.IncomingAuthTypeAnonymous {
256+
slog.Warn(
257+
"vMCP is configured with anonymous incoming auth; all anonymous sessions share a single sentinel binding, "+
258+
"so possession of a session ID is sufficient to act as that session from any source. "+
259+
"Anonymous mode is intended for development only.",
260+
"incoming_auth_type", config.IncomingAuthTypeAnonymous,
261+
)
262+
}
263+
255264
// Configure health monitoring if enabled.
256265
var healthMonitorConfig *health.MonitorConfig
257266
if vmcpCfg.Operational != nil &&
@@ -336,15 +345,11 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
336345
}
337346

338347
envReader := &env.OSReader{}
339-
sessionFactory, err := createSessionFactory(
340-
envReader.Getenv("VMCP_SESSION_HMAC_SECRET"),
341-
runtime.IsKubernetesRuntimeWithEnv(envReader),
342-
outgoingRegistry,
343-
agg,
344-
)
345-
if err != nil {
346-
return err
348+
if hmacSecret := envReader.Getenv("VMCP_SESSION_HMAC_SECRET"); hmacSecret != "" {
349+
slog.Debug("VMCP_SESSION_HMAC_SECRET is set but no longer used after #5306; ignoring",
350+
"env_var", "VMCP_SESSION_HMAC_SECRET")
347351
}
352+
sessionFactory := createSessionFactory(outgoingRegistry, agg)
348353

349354
// When the optimizer is enabled, its meta-tools must pass through the authz
350355
// response filter so they appear in tools/list.
@@ -645,51 +650,16 @@ func runDiscovery(
645650
return backends, backendClient, outgoingRegistry, nil
646651
}
647652

648-
// createSessionFactory creates a MultiSessionFactory with HMAC-SHA256 token binding.
649-
// The HMAC secret and Kubernetes detection are passed in as parameters (typically sourced
650-
// from the VMCP_SESSION_HMAC_SECRET environment variable and runtime environment detection
651-
// by the caller).
652-
//
653-
// Behavior:
654-
// - If hmacSecret is non-empty: validates length and creates factory with the secret.
655-
// - If running in Kubernetes without secret: returns error (production safety requirement).
656-
// - Otherwise: logs warning and creates factory with default insecure secret.
653+
// createSessionFactory creates a MultiSessionFactory backed by the provided outgoing
654+
// auth registry and optional aggregator. When agg is non-nil, sessions gain access
655+
// to aggregated backend metadata; pass nil for single-backend deployments.
657656
func createSessionFactory(
658-
hmacSecret string,
659-
isKubernetes bool,
660657
outgoingRegistry vmcpauth.OutgoingAuthRegistry,
661658
agg aggregator.Aggregator,
662-
) (vmcpsession.MultiSessionFactory, error) {
663-
const minRecommendedSecretLen = 32
664-
665-
opts := []vmcpsession.MultiSessionFactoryOption{}
659+
) vmcpsession.MultiSessionFactory {
660+
var opts []vmcpsession.MultiSessionFactoryOption
666661
if agg != nil {
667662
opts = append(opts, vmcpsession.WithAggregator(agg))
668663
}
669-
670-
if hmacSecret != "" {
671-
if secretLen := len(hmacSecret); secretLen < minRecommendedSecretLen {
672-
// G706: Safe - only logging integer length, not the secret itself.
673-
slog.Warn( //nolint:gosec
674-
"HMAC secret is shorter than recommended length - consider using a longer secret",
675-
"actual_length", secretLen,
676-
"recommended_length", minRecommendedSecretLen,
677-
)
678-
}
679-
slog.Info("using provided HMAC secret for session token binding")
680-
opts = append(opts, vmcpsession.WithHMACSecret([]byte(hmacSecret)))
681-
return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil
682-
}
683-
684-
// No secret provided — fail fast in Kubernetes (production environment).
685-
if isKubernetes {
686-
return nil, fmt.Errorf(
687-
"an HMAC secret is required when running in Kubernetes (set VMCP_SESSION_HMAC_SECRET). " +
688-
"Generate a secure secret with: openssl rand -base64 32",
689-
)
690-
}
691-
692-
// Development mode: use default insecure secret with warning.
693-
slog.Warn("no HMAC secret provided - using default insecure secret (NOT recommended for production)")
694-
return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil
664+
return vmcpsession.NewSessionFactory(outgoingRegistry, opts...)
695665
}

pkg/vmcp/cli/serve_test.go

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
authserverconfig "github.com/stacklok/toolhive/pkg/authserver"
1818
"github.com/stacklok/toolhive/pkg/groups"
1919
"github.com/stacklok/toolhive/pkg/vmcp"
20+
"github.com/stacklok/toolhive/pkg/vmcp/aggregator"
2021
aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks"
2122
clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks"
2223
"github.com/stacklok/toolhive/pkg/vmcp/config"
@@ -165,45 +166,27 @@ func newSessionFactoryMocks(t *testing.T) (*clientmocks.MockOutgoingAuthRegistry
165166
return clientmocks.NewMockOutgoingAuthRegistry(ctrl), aggregatormocks.NewMockAggregator(ctrl)
166167
}
167168

168-
func TestCreateSessionFactory_WithHMACSecret(t *testing.T) {
169+
func TestCreateSessionFactory(t *testing.T) {
169170
t.Parallel()
170-
registry, agg := newSessionFactoryMocks(t)
171-
factory, err := createSessionFactory("a-sufficiently-long-hmac-secret-value-32b", false, registry, agg)
172-
require.NoError(t, err)
173-
require.NotNil(t, factory)
174-
}
175-
176-
func TestCreateSessionFactory_HMACSecretExactly32Bytes(t *testing.T) {
177-
t.Parallel()
178-
registry, agg := newSessionFactoryMocks(t)
179-
factory, err := createSessionFactory("12345678901234567890123456789012", false, registry, agg)
180-
require.NoError(t, err)
181-
require.NotNil(t, factory)
182-
}
183-
184-
func TestCreateSessionFactory_ShortHMACSecret(t *testing.T) {
185-
t.Parallel()
186-
registry, agg := newSessionFactoryMocks(t)
187-
factory, err := createSessionFactory("short", false, registry, agg)
188-
require.NoError(t, err)
189-
require.NotNil(t, factory)
190-
}
191-
192-
func TestCreateSessionFactory_NoSecretNonKubernetes(t *testing.T) {
193-
t.Parallel()
194-
registry, agg := newSessionFactoryMocks(t)
195-
factory, err := createSessionFactory("", false, registry, agg)
196-
require.NoError(t, err)
197-
require.NotNil(t, factory)
198-
}
199-
200-
func TestCreateSessionFactory_NoSecretKubernetes(t *testing.T) {
201-
t.Parallel()
202-
registry, agg := newSessionFactoryMocks(t)
203-
factory, err := createSessionFactory("", true, registry, agg)
204-
require.Error(t, err)
205-
require.ErrorContains(t, err, "an HMAC secret is required when running in Kubernetes")
206-
require.Nil(t, factory)
171+
tests := []struct {
172+
name string
173+
useAgg bool
174+
}{
175+
{name: "with aggregator", useAgg: true},
176+
{name: "without aggregator", useAgg: false},
177+
}
178+
for _, tc := range tests {
179+
t.Run(tc.name, func(t *testing.T) {
180+
t.Parallel()
181+
registry, agg := newSessionFactoryMocks(t)
182+
var aggArg aggregator.Aggregator
183+
if tc.useAgg {
184+
aggArg = agg
185+
}
186+
factory := createSessionFactory(registry, aggArg)
187+
require.NotNil(t, factory)
188+
})
189+
}
207190
}
208191

209192
// TestRunDiscovery_KubernetesGroupNotFound exercises the Kubernetes-specific branch

pkg/vmcp/server/integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,8 +506,8 @@ func TestIntegration_AuditLogging(t *testing.T) {
506506
// table needed for tool calls and resource reads to be audit-logged correctly.
507507
auditSessionFactory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl)
508508
auditSessionFactory.EXPECT().
509-
MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
510-
DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
509+
MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
510+
DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
511511
mock := sessionmocks.NewMockMultiSession(ctrl)
512512
mock.EXPECT().ID().Return(id).AnyTimes()
513513
mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes()

pkg/vmcp/server/session_management_integration_test.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ func newNoopMockFactory(t *testing.T) *sessionfactorymocks.MockMultiSessionFacto
4848
t.Helper()
4949
ctrl := gomock.NewController(t)
5050
factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl)
51-
factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
52-
DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
51+
factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
52+
DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
5353
mock := sessionmocks.NewMockMultiSession(ctrl)
5454
mock.EXPECT().ID().Return(id).AnyTimes()
5555
mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes()
@@ -90,13 +90,12 @@ func newMockFactory(t *testing.T, ctrl *gomock.Controller, tools []vmcp.Tool) (*
9090
t.Helper()
9191
state := &mockFactoryState{}
9292
factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl)
93-
factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
94-
DoAndReturn(func(_ context.Context, id string, identity *auth.Identity, allowAnonymous bool, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
93+
factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
94+
DoAndReturn(func(_ context.Context, id string, _ *auth.Identity, _ []*vmcp.Backend) (vmcpsession.MultiSession, error) {
9595
state.makeWithIDCalled.Store(true)
96-
tokenHash := ""
97-
if identity != nil && identity.Token != "" && !allowAnonymous {
98-
tokenHash = "fake-hash-for-testing"
99-
}
96+
// All sessions carry MetadataKeyIdentityBinding so Terminate takes the
97+
// Phase 2 (storage.Delete) path. The sentinel value is sufficient for
98+
// tests that don't validate the binding content.
10099
mock := sessionmocks.NewMockMultiSession(ctrl)
101100
mock.EXPECT().ID().Return(id).AnyTimes()
102101
mock.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes()
@@ -105,7 +104,7 @@ func newMockFactory(t *testing.T, ctrl *gomock.Controller, tools []vmcp.Tool) (*
105104
mock.EXPECT().GetData().Return(nil).AnyTimes()
106105
mock.EXPECT().SetData(gomock.Any()).AnyTimes()
107106
mock.EXPECT().GetMetadata().Return(map[string]string{
108-
vmcpsession.MetadataKeyTokenHash: tokenHash,
107+
vmcpsession.MetadataKeyIdentityBinding: "unauthenticated",
109108
}).AnyTimes()
110109
mock.EXPECT().SetMetadata(gomock.Any(), gomock.Any()).AnyTimes()
111110
toolsCopy := make([]vmcp.Tool, len(tools))

pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ import (
2828
sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types"
2929
)
3030

31-
// hmacSecret is a fixed 32-byte secret used across all integration tests.
32-
var hmacSecret = []byte("test-hmac-secret-32bytes-exactly")
33-
3431
// ---------------------------------------------------------------------------
3532
// Helpers
3633
// ---------------------------------------------------------------------------
@@ -63,9 +60,8 @@ func newSharedRedisStorage(t *testing.T, mr *miniredis.Miniredis) transportsessi
6360
}
6461

6562
// newTestManagerWithSharedStorage creates a Manager backed by the given
66-
// DataStorage, a real session factory with the package-level hmacSecret, and
67-
// an ImmutableRegistry containing backends. Cleanup is registered via
68-
// t.Cleanup.
63+
// DataStorage, a real session factory, and an ImmutableRegistry containing
64+
// backends. Cleanup is registered via t.Cleanup.
6965
func newTestManagerWithSharedStorage(t *testing.T, storage transportsession.DataStorage, backends []*vmcp.Backend) *Manager {
7066
t.Helper()
7167
backendList := make([]vmcp.Backend, len(backends))
@@ -75,7 +71,6 @@ func newTestManagerWithSharedStorage(t *testing.T, storage transportsession.Data
7571
registry := vmcp.NewImmutableRegistry(backendList)
7672
factory := vmcpsession.NewSessionFactory(
7773
newUnauthenticatedAuthRegistry(t),
78-
vmcpsession.WithHMACSecret(hmacSecret),
7974
)
8075
sm, cleanup, err := New(storage, &FactoryConfig{Base: factory}, registry)
8176
require.NoError(t, err)
@@ -215,13 +210,26 @@ func TestHorizontalScaling_CrossPodHijackPrevention(t *testing.T) {
215210
storage := newSharedRedisStorage(t, mr)
216211
backend := startMCPBackend(t, "backend-alpha", "echo")
217212

213+
// Both alice and eve need Claims with iss+sub so the identity-binding
214+
// decorator can extract their (iss, sub) pairs (Token is not used for binding
215+
// in the #5306 model; Claims are the canonical source).
218216
identity := &auth.Identity{
219-
PrincipalInfo: auth.PrincipalInfo{Subject: "alice"},
220-
Token: "alice-bearer-token",
217+
PrincipalInfo: auth.PrincipalInfo{
218+
Subject: "alice",
219+
Claims: map[string]any{
220+
"iss": "https://idp.example",
221+
"sub": "alice",
222+
},
223+
},
221224
}
222225
wrongCaller := &auth.Identity{
223-
PrincipalInfo: auth.PrincipalInfo{Subject: "eve"},
224-
Token: "eve-bearer-token",
226+
PrincipalInfo: auth.PrincipalInfo{
227+
Subject: "eve",
228+
Claims: map[string]any{
229+
"iss": "https://idp.example",
230+
"sub": "eve",
231+
},
232+
},
225233
}
226234

227235
// Pod A: create session bound to alice.

pkg/vmcp/server/sessionmanager/session_manager.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -304,18 +304,11 @@ func (sm *Manager) CreateSession(
304304
// Resolve the caller identity (may be nil for anonymous access).
305305
identity, _ := auth.IdentityFromContext(ctx)
306306

307-
// Note: Token hash and salt are computed and stored by the session factory
308-
// (MakeSessionWithID below). Token binding enforcement happens at the session
309-
// level via validateCaller(), which uses HMAC-SHA256 with a per-session salt.
310-
311307
// List all available backends from the registry.
312308
backends := sm.listAllBackends(ctx)
313309

314310
// Build the fully-formed MultiSession using the SDK-assigned session ID.
315-
// Sessions created with an identity are bound to that identity (allowAnonymous=false).
316-
// Sessions created without an identity allow anonymous access (allowAnonymous=true).
317-
allowAnonymous := sessiontypes.ShouldAllowAnonymous(identity)
318-
sess, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, allowAnonymous, backends)
311+
sess, err := sm.factory.MakeSessionWithID(ctx, sessionID, identity, backends)
319312
if err != nil {
320313
sm.cleanupFailedPlaceholder(sessionID, placeholder)
321314
return nil, fmt.Errorf("Manager.CreateSession: failed to create multi-session: %w", err)
@@ -482,7 +475,7 @@ func (sm *Manager) Terminate(sessionID string) (isNotAllowed bool, err error) {
482475
return false, fmt.Errorf("Manager.Terminate: failed to load session %q: %w", sessionID, loadErr)
483476
}
484477

485-
if _, isFullSession := metadata[sessiontypes.MetadataKeyTokenHash]; isFullSession {
478+
if _, isFullSession := metadata[sessiontypes.MetadataKeyIdentityBinding]; isFullSession {
486479
// Phase 2 (full MultiSession): delete from storage. The cache entry will be
487480
// evicted lazily on the next Get when checkSession finds the session gone.
488481
if deleteErr := sm.storage.Delete(ctx, sessionID); deleteErr != nil {
@@ -701,16 +694,17 @@ func (sm *Manager) loadSession(sessionID string) (vmcpsession.MultiSession, erro
701694
}
702695

703696
// Don't restore placeholder sessions (Phase 2 never ran).
704-
// PreventSessionHijacking always writes MetadataKeyTokenHash during Phase 2
705-
// (empty sentinel for anonymous, non-empty hash for authenticated). Its
706-
// absence means Generate() stored this record but CreateSession() never
707-
// completed — treat it as "not found" rather than "corrupted".
697+
// BindSession always writes MetadataKeyIdentityBinding during Phase 2
698+
// (the unauthenticated sentinel for anonymous sessions, a bound (iss, sub)
699+
// binding for authenticated ones). Its absence means Generate() stored
700+
// this record but CreateSession() never completed — treat it as "not
701+
// found" rather than "corrupted".
708702
//
709703
// Note: this is intentionally different from RestoreSession's fail-closed
710704
// check (absent key → error). Here we know a placeholder's empty metadata
711705
// is valid storage state produced by Generate(), so we return the
712706
// SDK-standard ErrSessionNotFound instead of an error.
713-
if _, hashPresent := metadata[sessiontypes.MetadataKeyTokenHash]; !hashPresent {
707+
if _, bindingPresent := metadata[sessiontypes.MetadataKeyIdentityBinding]; !bindingPresent {
714708
return nil, transportsession.ErrSessionNotFound
715709
}
716710

0 commit comments

Comments
 (0)