#2534·mcp-use

client: getOAuthTokenExpiry fails on RFC 7515 Base64URL and multi-byte UTF-8 claims, dropping token expiration

Author: rohith500Created Sep 13, 2026Updated Sep 14, 2026
LabelsbuginvalidTypeScriptserverclient

Summary

In @mcp-use/client, getOAuthTokenExpiry in packages/client/src/react/token-expiry.ts decodes access token JWT payloads via:

typescript
const payload = JSON.parse(atob(tokens.access_token?.split(".")[1] ?? ""));

Under RFC 7515 §2, RFC 7519 §3, and RFC 4648 §5, JWT components are encoded using Base64URL, which substitutes + with -, / with _, and omits = padding.

Standard ECMAScript / DOM atob() throws DOMException: Invalid character whenever it encounters - or _, and in browser environments (Chrome, Firefox, Safari) it also throws on unpadded lengths not divisible by 4.

The Problem

  1. Silent Failure & Opaque Token Misclassification: When atob() throws on valid Base64URL characters, getOAuthTokenExpiry catches the error and silently assumes:

    typescript
    } catch {
      // Opaque tokens do not contain a JWT expiry claim.
    }

    Valid JWT access tokens are incorrectly treated as opaque tokens.

  2. Proactive Refresh Failure in useMcp: For major identity providers (Auth0, Keycloak, Supabase, Convex, Okta, Descope) that issue self-contained JWT access tokens, the authorization server commonly omits expires_in in the token JSON response because the expiration is already embedded in the JWT payload (exp). When expires_in is absent and atob() throws, getOAuthTokenExpiry returns undefined. In packages/client/src/react/useMcp.ts:1184, expires_at is set to undefined. Consequently, useMcp and client applications never detect token expiration and cannot schedule proactive token refresh, causing active user sessions to crash with unhandled 401 Unauthorized connection drops.

  3. Unicode UTF-8 Claims Corruption & SyntaxError: RFC 7519 §3 mandates that the JWT claims set is a UTF-8 octet sequence. Naive atob() decodes bytes into Latin-1 code units (U+0000 to U+00FF), corrupting multi-byte UTF-8 sequences (international user names, emails with unicode, localized claim values, emojis). If any byte falls into the ASCII control range (0x000x1F), JSON.parse throws a SyntaxError, once again discarding the valid exp claim.

Reproduction

typescript
import { getOAuthTokenExpiry } from "@mcp-use/client";

// A valid JWT with a claim containing character 62 ('-') in Base64URL:
const payload = { exp: 1800000000, pad: "¾" };
const b64u = Buffer.from(JSON.stringify(payload)).toString("base64url");
// b64u = "eyJleHAiOjE4MDAwMDAwMDAsInBhZCI6IsK-In0"
const token = `header.${b64u}.sig`;

// Current behavior:
console.log(getOAuthTokenExpiry({ access_token: token }));
// ❌ Output: undefined (silently caught and discarded!)

Proposed Fix

Replace naive atob() with a universal, zero-dependency, standards-compliant Base64URL-to-UTF-8 decoder:

  1. Translate URL-safe characters (- to +, _ to /).
  2. Restore required = padding for atob().
  3. Decode bytes using new TextDecoder().decode(bytes).
  4. Validate that the token has 3 dot-separated parts and verify that payload.exp is a positive finite number before returning milliseconds.
  5. Retain fallback to expires_in for genuinely opaque or malformed tokens.