From 1efa8a3894218a897d558c042a8edc1151dd47ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Jul 2026 10:14:00 -0700 Subject: [PATCH] fix(auth): keep MCP sessions authorized past token expiry McpSessionRegistry minted a per-thread MCP bearer credential once at provider-session start with a hard 8h maximum lifetime and no refresh path. The token is handed to the agent process as a static header, so once the absolute cap passed, the still-healthy session's MCP calls started failing 401 with no way to recover short of a manual re-authorization (observed 2026-07-03: "t3-code requires re-authorization (token expired)" mid-session). Have `resolve()` transparently slide a credential's expiry forward on every authenticated use, so actively-used sessions never approach the cap, and bump the default cap itself to 30 days (matching SessionStore's browser-session TTL) since the credential is already tightly scoped and explicitly revoked on session stop/error/shutdown. The existing 30-minute idle timeout still reclaims credentials for threads that go truly quiet. Co-Authored-By: Claude Fable 5 --- .../server/src/mcp/McpSessionRegistry.test.ts | 42 +++++++++++++++++++ apps/server/src/mcp/McpSessionRegistry.ts | 31 ++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index a91d98febd8..f44318fd6cc 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -88,3 +88,45 @@ it.effect("expires credentials after inactivity", () => expect(yield* registry.resolve(token)).toBeUndefined(); }), ); + +it.effect("expires a never-used credential once it hits the maximum lifetime", () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-3"), + providerInstanceId: ProviderInstanceId.make("claude"), + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + // Stay within the idle window but past the maximum lifetime. + timestamp += 1_001; + expect(yield* registry.resolve(token)).toBeUndefined(); + }), +); + +it.effect( + "transparently renews a credential's expiry on every use, so an actively-used session outlives the maximum lifetime", + () => + Effect.gen(function* () { + let timestamp = 1_000; + const registry = yield* makeRegistry(() => timestamp); + const threadId = ThreadId.make("thread-4"); + const issued = yield* registry.issue({ + threadId, + providerInstanceId: ProviderInstanceId.make("claude"), + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + + // Repeatedly use the credential at an interval shorter than both the + // idle timeout and the maximum lifetime, well past the point where the + // original mint-time expiry (issuedAt + maximumLifetimeMs = 2_000) + // would have killed it without renewal. + for (let i = 0; i < 20; i++) { + timestamp += 90; + const resolved = yield* registry.resolve(token); + expect(resolved?.threadId).toBe(threadId); + } + + expect(timestamp).toBeGreaterThan(issued.expiresAt); + }), +); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 67c4f2f0ff0..c8a5c062e4d 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -53,7 +53,23 @@ export interface McpSessionRegistryOptions { } const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; -const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; +// This bounds how long an MCP bearer credential stays valid *without any use* +// past its mint time before `resolve` refreshes it (see below) — it is not a +// hard cap on session length. Agent sessions routinely run far longer than a +// single workday (multi-hour coding sessions, overnight/scheduled runs), and +// the credential is minted once at provider-session start and handed to the +// agent process as a static header, so there is no way for the agent to pick +// up a rotated token later. A short absolute cap therefore silently kills the +// MCP connection out from under an otherwise-healthy session (observed +// 2026-07-03: "t3-code requires re-authorization (token expired)" mid-session). +// The credential is already tightly scoped (random 256-bit token, single +// thread + provider instance, held only in memory, and explicitly revoked on +// session stop/error/environment shutdown — see `revokeThread`/`revokeAll`), +// so a long backstop here doesn't meaningfully widen exposure; it's paired +// with `resolve` sliding the expiry forward on every authenticated use so +// active sessions never approach the cap, and the existing idle timeout still +// reclaims credentials for threads that truly go quiet. +const DEFAULT_MAXIMUM_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000; const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -147,8 +163,17 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const record = current.get(tokenHash); if (!record) return [undefined, { records: current }] as const; const next = new Map(current); - next.set(tokenHash, { ...record, lastUsedAt: timestamp }); - return [record.scope, { records: next }] as const; + // Transparently re-issue: every authenticated use of a still-valid + // credential pushes its absolute expiry forward, so a session that + // keeps calling MCP tools never runs into the maximum-lifetime cap + // above. Only sessions that stop using MCP entirely fall back to + // that cap (or the idle timeout, whichever is hit first). + const renewedScope: McpInvocationContext.McpInvocationScope = { + ...record.scope, + expiresAt: Math.max(record.scope.expiresAt, timestamp + maximumLifetimeMs), + }; + next.set(tokenHash, { ...record, scope: renewedScope, lastUsedAt: timestamp }); + return [renewedScope, { records: next }] as const; }); }, );