Skip to content

OAuth: an expired opaque access token is replayed instead of re-authorized — token_expired is answered from an unknowable local expiry #2051

Closed

Description

@Oldharlem

Which version line?

v2 — current (@modelcontextprotocol/inspector@latest)

Which client?

All / shared core — the predicate lives in core/, and web, CLI and TUI all consume it.

Inspector version

2.2.0

Node version

v24.14.1

Operating system

macOS (Darwin 25.2.0), Chrome

Transport

Streamable HTTP

MCP server under inspection

A local Rails/Doorkeeper-backed MCP server at https://localhost:3000/mcp — protocol era auto, OAuth yes (authorization_code + PKCE, confidential client, 900s access tokens).

Honest scoping: I found this by reading core/ while debugging persisted OAuth state, not by watching it fail against a live opaque-token AS. The reproduction below is exact at unit level (a test that fails on v2/main and passes with the fix); the end-to-end sequence is derived from the call sites, which I've cited so you can judge it yourself.

Steps to reproduce

  1. Connect to an OAuth-protected Streamable HTTP MCP server whose authorization server issues opaque (non-JWT) access tokens — the default for Doorkeeper, Keycloak configured for opaque tokens, GitHub-style gho_…, and many others.
  2. Authorize. Storage records something like { access_token: "<opaque>", token_type: "Bearer", expires_in: 900 }.
  3. Wait until that token has expired server-side.
  4. Reconnect, or make any call that now 401s.

Expected behavior

The resource server rejected the credential, so the Inspector re-authorizes — AuthRecoveryRequiredError → prepareOAuthRedirect({ authKind: "reauth" }) in the web client, the equivalent interactive flow in CLI/TUI.

Actual behavior

checkAuthChallengeSatisfied() reports the challenge as already satisfied, so the caller reconnects with the same expired token. From clients/web/src/App.tsx (connect path):

if (err instanceof AuthRecoveryRequiredError) {
  if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
    connectStartRef.current = Date.now();
    await client.connect();          // ← retried with the same dead token
    return;
  }
  prepareOAuthRedirect({ ..., authKind: "reauth", ... });   // ← never reached
}

The server 401s again and the loop repeats: no re-auth redirect, no ReAuthBanner. The only way out is Clear OAuth state, which deletes the whole server key from oauth.json — including preregisteredClientInformation, i.e. the client id and secret the user typed in.

The same short-circuit is in clients/cli/src/cliOAuth.ts (lines 241 and 252 — an early return instead of authorizing) and clients/tui/src/App.tsx (lines 701, 720).

Root cause

core/mcp/oauthManager.ts (~517):

if (challenge.reason !== "insufficient_scope") {
  return challenge.reason === "token_expired" && isAccessTokenUsable(tokens);
}

core/auth/connection-state.ts:28:

export function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean {
  if (!tokens?.access_token) return false;
  return !isJwtExpired(tokens.access_token);
}

core/auth/ema/jwt.ts:33 — isJwtExpired returns false when jwtExpiresAtMs() is undefined, i.e. whenever the token is not a JWT or carries no exp:

const exp = jwtExpiresAtMs(token);
if (exp === undefined) return false;

So "we cannot tell" is read as "still valid", and an opaque token is unexpirable by construction. Nothing else can correct it: expires_in never becomes an absolute expiry anywhere in core/ (it is only carried across the wire in core/mcp/remote/types.ts and core/mcp/remote/node/tokenAuthProvider.ts), and no expires_at is recorded at save time.

Two things widen the blast radius:

  • A bare 401 maps to token_expired (core/auth/challenge.ts:250, "Bare 401 without a Bearer error code — treat as expired token"), so this is not limited to servers that send WWW-Authenticate: … error="invalid_token".
  • The real recovery in handleAuthChallenge (silent refresh, then interactive) is never reached, because callers consult checkAuthChallengeSatisfied first — exactly as that method's own doc comment says they do.

Suggested fix

Keep isAccessTokenUsable optimistic — it is the right default for displaying authorization state, and flipping it would break every opaque-token server's "Authorized" indicator. Add a pessimistic sibling for the one moment where a resource server has explicitly disagreed with us:

export function isAccessTokenProvablyUnexpired(
  tokens: OAuthTokens | undefined,
): boolean {
  if (!tokens?.access_token) return false;
  if (jwtExpiresAtMs(tokens.access_token) === undefined) return false;
  return !isJwtExpired(tokens.access_token);
}

and use it in checkAuthChallengeSatisfied. Absence of local evidence then no longer outvotes the server's verdict, while a JWT that is provably still alive keeps its cheap short-circuit.

Note this flips one existing expectation, which currently pins the buggy behaviour with an opaque token:

it("returns true for token_expired when a usable access token exists", async () => {
  storageOf(params).getTokens.mockResolvedValue({
    access_token: "tok",            // ← opaque
    token_type: "Bearer",
  });
  expect(
    await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }),
  ).toBe(true);
});

Already prototyped a fix?

Yes — per CONTRIBUTING.md I am not sending a diff. The prompt that produces the change:

In core/auth/connection-state.ts, add an exported isAccessTokenProvablyUnexpired(tokens) next to isAccessTokenUsable. It returns true only when the access token is a JWT whose exp is still in the future, using the existing jwtExpiresAtMs and isJwtExpired helpers from ./ema/jwt.js (so the current 60s skew is preserved); a missing token, or one with no parseable exp, returns false. Document why the two predicates differ: storage keeps no absolute expiry for opaque tokens, because expires_in is relative to an issuance instant that is never recorded, so isAccessTokenUsable treats an unknown expiry as usable — correct for displaying authorization state, wrong once a resource server has rejected the credential.

In core/mcp/oauthManager.ts, make checkAuthChallengeSatisfied use the new predicate for the token_expired branch instead of isAccessTokenUsable, and update its doc comment: token_expired is now satisfied only by a provably unexpired JWT; an opaque token has no local expiry evidence, so the server's verdict stands and re-authorization proceeds. insufficient_scope, invalid_token and unauthorized are unchanged.

Update clients/web/src/test/core/mcp/oauthManager.test.ts: the existing "returns true for token_expired when a usable access token exists" case uses an opaque "tok" and pins the old behaviour — rewrite it around a JWT with a future exp, and add siblings for an expired JWT and for an opaque token (both false), the latter commented as the regression it guards. Add a jwtWithExp helper matching the one in the EMA tests. In clients/web/src/test/core/auth/connection-state.test.ts, add a describe block for the new predicate: future-exp JWT true; expired JWT false; inside the skew window false; opaque token false while isAccessTokenUsable is true for the same token; no access token false.

How I verified it

  • cd clients/web && npx vitest run --project=unit src/test/core/auth/connection-state.test.ts src/test/core/mcp/oauthManager.test.ts — 116 passed.
  • Full web unit suite (npm run test in clients/web) — 273 files / 4339 tests passed. (One run had an unrelated flake in AppControls.test.tsx "does not invoke onSelectApp when the already-selected app is clicked"; it passes in isolation both with and without the change, and a second full run was green.)
  • prettier --check and eslint clean on the four touched files.
  • Confirmed the new opaque-token test fails on unmodified v2/main (it returns true) and passes with the change.

A branch with the change and tests, if it is useful as reference: https://github.com/Oldharlem/inspector/tree/v2/fix/opaque-token-replay-on-token-expired (please don't treat it as a PR — I'm following the issues-only policy).

Related, lower priority — say the word and I'll file it separately

Recovering from bad cached auth state is currently all-or-nothing: "Clear OAuth state" removes the entire server key (clearServerState in core/auth/store.ts), so a user re-testing an auth server loses the preregistered client id/secret every time. A "re-run discovery / drop tokens, keep client credentials" action would cover that, and MCP_STORAGE_DIR — which does give a clean ephemeral store, and which I verified works for the web client — is documented only in clients/cli/README.md and the v1→v2 migration table, not where a web user would find it.

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is not a security vulnerability report.

Activity

  1. added
    v2Issues and PRs for v2
    bugSomething isn't working
    on Aug 19, 2026
  2. cliffhall commented on Aug 19, 2026

    @cliffhall
    Member

    Triage: Priority Medium (total 8)

    • Severity 4 — replaying a credential the resource server already rejected instead of re-authorizing leaves the connect workflow unusable against any authorization server issuing opaque tokens, which is a common configuration
    • Urgency 3 — wanted this milestone
    • Bonuses: +1 bug label

    Thanks for the explicit scoping note on what was reproduced at unit level versus derived from the call sites — that distinction is genuinely useful at triage.

    Board: #28, Status Incoming (awaiting maintainer review — no milestone yet).

  3. added this to the v2.4.0 milestone on Aug 24, 2026
  4. added
    authIssues and PRs related to authorization
    on Aug 24, 2026
  5. self-assigned this
    on Aug 24, 2026
  6. added a commit that references this issue on Aug 26, 2026
    d41e4e1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

authIssues and PRs related to authorizationbugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions