Skip to content

Commit 1e4d433

Browse files
committed
Fix MCP Apps marker refresh and relay JWT expiry
1 parent 0df1c59 commit 1e4d433

3 files changed

Lines changed: 59 additions & 13 deletions

File tree

src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const scopedToken = jwt({
1515
managed_auth_session_id: "session_1",
1616
exp: 4102444800,
1717
});
18+
const expiredScopedToken = jwt({
19+
iss: "kernel-api",
20+
managed_auth_session_id: "session_1",
21+
exp: 1,
22+
});
1823

1924
function request(
2025
path: string,
@@ -70,6 +75,15 @@ describe("managed-auth relay", () => {
7075
);
7176
expect(apiKey.status).toBe(401);
7277
expectCors(apiKey);
78+
79+
const expired = await proxyManagedAuthRequest(
80+
request("/managed-auth-proxy/auth/connections/c_1", {
81+
headers: { authorization: `Bearer ${expiredScopedToken}` },
82+
}),
83+
["c_1"],
84+
);
85+
expect(expired.status).toBe(401);
86+
expectCors(expired);
7387
});
7488

7589
test("allows unauthenticated exchange and strips cookies and arbitrary headers", async () => {

src/app/managed-auth-proxy/auth/connections/[...path]/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,14 @@ function managedAuthAuthorization(request: Request): string | null {
5353
const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
5454
if (!match) return null;
5555
const claims = decodeJwtPayload(match[1]);
56+
const nowSeconds = Math.floor(Date.now() / 1000);
5657
if (
5758
claims?.iss !== "kernel-api" ||
5859
typeof claims.managed_auth_session_id !== "string" ||
5960
!claims.managed_auth_session_id ||
60-
typeof claims.exp !== "number"
61+
typeof claims.exp !== "number" ||
62+
!Number.isFinite(claims.exp) ||
63+
claims.exp <= nowSeconds
6164
) {
6265
return null;
6366
}

src/lib/redis.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,9 @@ export { client as redisClient };
152152
// (one McpServer per request), so a client's declared
153153
// `io.modelcontextprotocol/ui` capability from initialize is not visible to
154154
// later tool calls on the same connection. The route layer records it here,
155-
// keyed by the bearer token, so app-only tools can fail closed on hosts that
156-
// never declared MCP Apps support.
155+
// keyed by the bearer token plus JWT session claim when available, so
156+
// refreshed access tokens from the same OAuth session keep the marker alive
157+
// while app-only tools still fail closed on hosts that never declared support.
157158
const MCP_APPS_KEY_PREFIX = "mcp-apps:";
158159

159160
function hashBearerToken(token: string): string {
@@ -164,6 +165,33 @@ function hashBearerToken(token: string): string {
164165
return createHmac("sha256", secretKey).update(token).digest("hex");
165166
}
166167

168+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
169+
const parts = token.split(".");
170+
if (parts.length !== 3) return null;
171+
try {
172+
return JSON.parse(
173+
Buffer.from(parts[1], "base64url").toString("utf8"),
174+
) as Record<string, unknown>;
175+
} catch {
176+
return null;
177+
}
178+
}
179+
180+
function mcpAppsMarkerKeys(token: string): string[] {
181+
const keys = [`${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`];
182+
const claims = decodeJwtPayload(token);
183+
const sessionId =
184+
typeof claims?.sid === "string" && claims.sid
185+
? claims.sid
186+
: typeof claims?.session_id === "string" && claims.session_id
187+
? claims.session_id
188+
: null;
189+
if (sessionId) {
190+
keys.push(`${MCP_APPS_KEY_PREFIX}session:${hashOpaqueToken(sessionId)}`);
191+
}
192+
return keys;
193+
}
194+
167195
export async function markMcpAppsClient({
168196
token,
169197
ttlSeconds,
@@ -172,10 +200,10 @@ export async function markMcpAppsClient({
172200
ttlSeconds: number;
173201
}): Promise<void> {
174202
await ensureConnected();
175-
const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`;
176-
await withReconnect(() =>
177-
client.setEx(key, Math.max(60, Math.floor(ttlSeconds)), "1"),
178-
);
203+
const ttl = Math.max(60, Math.floor(ttlSeconds));
204+
for (const key of mcpAppsMarkerKeys(token)) {
205+
await withReconnect(() => client.setEx(key, ttl, "1"));
206+
}
179207
}
180208

181209
/**
@@ -190,12 +218,13 @@ export async function hasMcpAppsClient({
190218
ttlSeconds: number;
191219
}): Promise<boolean> {
192220
await ensureConnected();
193-
const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`;
194-
const value = await withReconnect(() => client.get(key));
195-
if (value === null) return false;
196-
await withReconnect(() =>
197-
client.expire(key, Math.max(60, Math.floor(ttlSeconds))),
198-
);
221+
const ttl = Math.max(60, Math.floor(ttlSeconds));
222+
const keys = mcpAppsMarkerKeys(token);
223+
const values = await withReconnect(() => client.mGet(keys));
224+
if (!values.some((value) => value !== null)) return false;
225+
for (const key of keys) {
226+
await withReconnect(() => client.expire(key, ttl));
227+
}
199228
return true;
200229
}
201230

0 commit comments

Comments
 (0)