client: getOAuthTokenExpiry fails on RFC 7515 Base64URL and multi-byte UTF-8 claims, dropping token expiration
Summary
In @mcp-use/client, getOAuthTokenExpiry in packages/client/src/react/token-expiry.ts decodes access token JWT payloads via:
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
Silent Failure & Opaque Token Misclassification: When
atob()throws on valid Base64URL characters,getOAuthTokenExpirycatches the error and silently assumes:} catch { // Opaque tokens do not contain a JWT expiry claim. }Valid JWT access tokens are incorrectly treated as opaque tokens.
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 omitsexpires_inin the token JSON response because the expiration is already embedded in the JWT payload (exp). Whenexpires_inis absent andatob()throws,getOAuthTokenExpiryreturnsundefined. Inpackages/client/src/react/useMcp.ts:1184,expires_atis set toundefined. Consequently,useMcpand client applications never detect token expiration and cannot schedule proactive token refresh, causing active user sessions to crash with unhandled401 Unauthorizedconnection drops.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+0000toU+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 (0x00–0x1F),JSON.parsethrows aSyntaxError, once again discarding the validexpclaim.
Reproduction
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:
- Translate URL-safe characters (
-to+,_to/). - Restore required
=padding foratob(). - Decode bytes using
new TextDecoder().decode(bytes). - Validate that the token has 3 dot-separated parts and verify that
payload.expis a positive finite number before returning milliseconds. - Retain fallback to
expires_infor genuinely opaque or malformed tokens.
Source: mcp-use/mcp-use