Merge branch 'v2/main' into v2/fix/2064-csp-drop-warning

This commit is contained in:
Cliff Hall 2026-08-24 08:28:08 -04:00 • committed by GitHub
commit 684bde5ba5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 191 additions and 10 deletions

View file

@ -2,6 +2,8 @@ import { describe, it, expect, vi } from "vitest";
import {
buildOAuthConnectionState,
hasPersistedOAuthServerState,
isAccessTokenProvablyUnexpired,
isAccessTokenUsable,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "@inspector/core/auth/connection-state.js";
@ -257,3 +259,102 @@ describe("buildOAuthConnectionState", () => {
expect(state.ema).not.toHaveProperty("idToken");
});
});
/** Minimal unsigned JWT carrying only `exp` (epoch seconds). */
function jwtWithExp(expSec: number): string {
const payload = btoa(JSON.stringify({ exp: expSec }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `header.${payload}.sig`;
}
const nowSec = () => Math.floor(Date.now() / 1000);
describe("isAccessTokenProvablyUnexpired", () => {
it("is true for a JWT whose exp is in the future", () => {
expect(
isAccessTokenProvablyUnexpired({
access_token: jwtWithExp(nowSec() + 3600),
token_type: "Bearer",
}),
).toBe(true);
});
it("is false for a JWT whose exp has passed", () => {
expect(
isAccessTokenProvablyUnexpired({
access_token: jwtWithExp(nowSec() - 60),
token_type: "Bearer",
}),
).toBe(false);
});
it("is false inside the 60s expiry skew window", () => {
expect(
isAccessTokenProvablyUnexpired({
access_token: jwtWithExp(nowSec() + 30),
token_type: "Bearer",
}),
).toBe(false);
});
it("is false for an opaque token, where isAccessTokenUsable is true", () => {
// The #2051 regression: no local expiry evidence must not read as "valid".
const tokens = { access_token: "opaque-tok", token_type: "Bearer" };
expect(isAccessTokenProvablyUnexpired(tokens)).toBe(false);
expect(isAccessTokenUsable(tokens)).toBe(true);
});
it("is false for a JWT carrying no exp claim", () => {
const payload = btoa(JSON.stringify({ sub: "user" }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(
isAccessTokenProvablyUnexpired({
access_token: `header.${payload}.sig`,
token_type: "Bearer",
}),
).toBe(false);
});
it("is false for a two-segment token whose payload decodes to an exp", () => {
// jwtExpiresAtMs only requires two dot-separated segments, so a structured
// opaque token carrying a decodable `{"exp":...}` would otherwise read as
// proof of validity. isJwtFormat is what rejects it.
const payload = btoa(JSON.stringify({ exp: nowSec() + 3600 }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(
isAccessTokenProvablyUnexpired({
access_token: `opaque.${payload}`,
token_type: "Bearer",
}),
).toBe(false);
});
it("is false for a four-segment token carrying a decodable exp", () => {
const payload = btoa(JSON.stringify({ exp: nowSec() + 3600 }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(
isAccessTokenProvablyUnexpired({
access_token: `header.${payload}.sig.extra`,
token_type: "Bearer",
}),
).toBe(false);
});
it("is false when there is no access token", () => {
expect(isAccessTokenProvablyUnexpired(undefined)).toBe(false);
expect(
isAccessTokenProvablyUnexpired({
access_token: "",
token_type: "Bearer",
}),
).toBe(false);
});
});

View file

@ -27,6 +27,15 @@ const mockedMcpAuth = vi.mocked(mcpAuth);
const SERVER_URL = "https://example.com/mcp";
/** Minimal unsigned JWT carrying only `exp` (epoch seconds). */
function jwtWithExp(expSec: number): string {
const payload = btoa(JSON.stringify({ exp: expSec }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `header.${payload}.sig`;
}
function createMockParams(
overrides?: Partial<OAuthManagerParams>,
): OAuthManagerParams {
@ -988,10 +997,10 @@ describe("OAuthManager", () => {
).toBe(false);
});
it("returns true for token_expired when a usable access token exists", async () => {
it("returns true for token_expired when a provably unexpired JWT exists", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600),
token_type: "Bearer",
});
const manager = new OAuthManager(params);
@ -1001,6 +1010,36 @@ describe("OAuthManager", () => {
).toBe(true);
});
it("returns false for token_expired when the stored JWT has expired", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: jwtWithExp(Math.floor(Date.now() / 1000) - 60),
token_type: "Bearer",
});
const manager = new OAuthManager(params);
expect(
await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }),
).toBe(false);
});
it("returns false for token_expired when the stored token is opaque", async () => {
// Regression guard for #2051: an opaque token has no local expiry
// evidence, so it must not outvote the resource server's verdict and
// short-circuit re-authorization into replaying a dead credential.
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "opaque-tok",
token_type: "Bearer",
expires_in: 900,
});
const manager = new OAuthManager(params);
expect(
await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }),
).toBe(false);
});
it("returns false for invalid_token even when a locally valid token exists", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({