Merge pull request #2105 from modelcontextprotocol/v2/fix/2051-opaque-token-replay
fix(auth): don't answer `token_expired` from an unknowable local expiry
This commit is contained in:
commit
c0ad49f8a3
5 changed files with 191 additions and 10 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
|
||||
import { getEmaIdpLoginState, normalizeIdpIssuer } from "./ema/idpSession.js";
|
||||
import { idpOAuthStorageKey } from "./ema/storage.js";
|
||||
import { isJwtExpired } from "./ema/jwt.js";
|
||||
import { isJwtExpired, isJwtFormat, jwtExpiresAtMs } from "./ema/jwt.js";
|
||||
import type { OAuthStorage } from "./storage.js";
|
||||
import type {
|
||||
AuthProtocol,
|
||||
|
|
@ -24,12 +24,48 @@ export interface BuildOAuthConnectionStateParams {
|
|||
flowState?: OAuthFlowState;
|
||||
}
|
||||
|
||||
/** True when persisted tokens include a non-expired access token (JWT exp when parseable). */
|
||||
/**
|
||||
* True when persisted tokens include a non-expired access token (JWT exp when parseable).
|
||||
*
|
||||
* Optimistic: an access token whose expiry cannot be determined locally — an
|
||||
* opaque token, or a JWT with no `exp` — counts as usable. Storage keeps no
|
||||
* absolute expiry for those, because `expires_in` is relative to an issuance
|
||||
* instant that is never recorded. That is the right default for *displaying*
|
||||
* authorization state (an opaque-token server would otherwise always read as
|
||||
* unauthorized), and the wrong one once a resource server has actually rejected
|
||||
* the credential — see {@link isAccessTokenProvablyUnexpired}.
|
||||
*/
|
||||
export function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
|
||||
if (!tokens?.access_token) return false;
|
||||
return !isJwtExpired(tokens.access_token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pessimistic counterpart to {@link isAccessTokenUsable}: true only when the
|
||||
* access token is a JWT carrying an `exp` that is still in the future (same 60s
|
||||
* skew as {@link isJwtExpired}).
|
||||
*
|
||||
* Absence of local expiry evidence returns `false` rather than `true`, so it
|
||||
* cannot outvote a resource server that has explicitly said the token is
|
||||
* expired. Use this — not `isAccessTokenUsable` — to decide whether a
|
||||
* `token_expired` challenge is already satisfied.
|
||||
*
|
||||
* The shape is checked with {@link isJwtFormat} before the `exp` is trusted.
|
||||
* {@link jwtExpiresAtMs} is deliberately lenient — it parses the second of any
|
||||
* two-or-more dot-separated segments — so a structured opaque token that merely
|
||||
* happens to carry a decodable `{"exp":…}` segment would otherwise be read as
|
||||
* proof, which is exactly the "guess beats the server" failure this predicate
|
||||
* exists to remove.
|
||||
*/
|
||||
export function isAccessTokenProvablyUnexpired(
|
||||
tokens: OAuthTokens | undefined,
|
||||
): boolean {
|
||||
if (!tokens?.access_token) return false;
|
||||
if (!isJwtFormat(tokens.access_token)) return false;
|
||||
if (jwtExpiresAtMs(tokens.access_token) === undefined) return false;
|
||||
return !isJwtExpired(tokens.access_token);
|
||||
}
|
||||
|
||||
function resolveClient(
|
||||
preregistered: OAuthClientInformation | undefined,
|
||||
dynamic: OAuthClientInformation | undefined,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
import {
|
||||
buildOAuthConnectionState,
|
||||
hasPersistedOAuthServerState,
|
||||
isAccessTokenUsable,
|
||||
isAccessTokenProvablyUnexpired,
|
||||
isServerOAuthConfigured,
|
||||
protocolFromOAuthConfig,
|
||||
} from "../auth/connection-state.js";
|
||||
|
|
@ -494,9 +494,12 @@ export class OAuthManager {
|
|||
* satisfied without an authorization-server round-trip.
|
||||
*
|
||||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||||
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
|
||||
* token is already in storage. `invalid_token` and `unauthorized` always
|
||||
* return `false` — the resource server explicitly rejected the credential.
|
||||
* SEP-2350 union. For `token_expired`, returns `true` only when storage holds
|
||||
* a *provably* unexpired token — a JWT whose `exp` is still in the future. An
|
||||
* opaque token carries no local expiry evidence, so the resource server's
|
||||
* verdict stands and re-authorization proceeds. `invalid_token` and
|
||||
* `unauthorized` always return `false` — the resource server explicitly
|
||||
* rejected the credential.
|
||||
*/
|
||||
async checkAuthChallengeSatisfied(
|
||||
challenge: AuthChallenge,
|
||||
|
|
@ -514,7 +517,8 @@ export class OAuthManager {
|
|||
|
||||
if (challenge.reason !== "insufficient_scope") {
|
||||
return (
|
||||
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
|
||||
challenge.reason === "token_expired" &&
|
||||
isAccessTokenProvablyUnexpired(tokens)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -206,7 +206,8 @@ export type AuthChallengeOutcome =
|
|||
|
||||
Read-only check against **current storage** (and token expiry helpers). Used before starting visible OAuth — especially when a background browser tab regains focus and another tab may have already re-authenticated. Does **not** call the authorization server.
|
||||
|
||||
- **`token_expired` / `unauthorized`:** valid non-expired access token in storage.
|
||||
- **`token_expired`:** a **provably** unexpired access token in storage — a JWT whose `exp` is still in the future (60s skew). Storage records no absolute expiry for an opaque token (`expires_in` is relative to an issuance instant that is never persisted), so "expiry unknown" returns **false**: the resource server has said the credential is dead, and no local evidence contradicts it. Otherwise an opaque token would be replayed forever ([#2051](https://github.com/modelcontextprotocol/inspector/issues/2051)).
|
||||
- **`invalid_token` / `unauthorized`:** always **false** — the resource server explicitly rejected the credential.
|
||||
- **`insufficient_scope`:** stored/token scope is a superset of required / union scopes. Empty scope on an `insufficient_scope` challenge returns **false** (do not short-circuit).
|
||||
|
||||
### Strategy by protocol
|
||||
|
|
|
|||
Loading…
Reference in a new issue