santifer/career-ops · error · Error

Gmail token refresh returned no access_token

Error message

Gmail token refresh returned no access_token

What it means

Thrown by `getAccessToken` in the gmail plugin (plugins/gmail/index.mjs:47) when the token-refresh POST returns HTTP 200 OK but the JSON body has no `access_token` field. Google's token endpoint is expected to return `{ access_token, expires_in, ... }`; a 200 with no access_token is an unexpected/ malformed success response. This guard prevents returning `undefined` and passing it downstream as a bearer token.

Source

Thrown at plugins/gmail/index.mjs:47

const STATE_PATH = 'data/gmail-state.json'; // the plugin's own processed-id cursor

/** Exchange the long-lived refresh token for a short-lived access token. */
async function getAccessToken({ clientId, clientSecret, refreshToken }, fetchFn = globalThis.fetch) {
  const res = await fetchFn(TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
    }),
  });
  if (!res.ok) {
    throw new Error(`Gmail token refresh failed: ${res.status} ${(await res.text()).slice(0, 200)}`);
  }
  const data = await res.json();
  if (!data.access_token) throw new Error('Gmail token refresh returned no access_token');
  return data.access_token;
}

function loadProcessedIds() {
  if (!existsSync(STATE_PATH)) return new Set();
  try {
    const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'));
    return new Set(state.processed_message_ids || []);
  } catch {
    return new Set();
  }
}

function saveProcessedIds(ids) {
  try {
    mkdirSync('data', { recursive: true });
    writeFileSync(STATE_PATH, JSON.stringify({ processed_message_ids: [...ids] }, null, 2), 'utf-8');
  } catch (err) {

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Log `data` (without secrets) to see the actual response shape returned.
  2. Confirm TOKEN_URL points at `https://oauth2.googleapis.com/token`.
  3. If a proxy is intercepting, bypass it for oauth2.googleapis.com.
  4. Retry once — if persistent and the body is clearly not a token response, investigate the network path.
Defensive patterns

Strategy: validation

Validate before calling

// Sanity-check the token endpoint response shape in a dry-run.
async function probeTokenEndpoint(url = TOKEN_URL) {
  // A harmless OPTIONS probe to confirm the host is the real Google endpoint.
  const res = await fetch(url, { method: 'OPTIONS' });
  if (!res.ok && res.status !== 405) {
    throw new Error(`Token endpoint ${url} looks intercepted (status ${res.status}).`);
  }
}
await probeTokenEndpoint();

Type guard

/** @param {unknown} data */
function hasAccessToken(data) {
  return data != null && typeof data.access_token === 'string' && data.access_token.length > 0;
}

Try / catch

try {
  const token = await getAccessToken(creds);
} catch (err) {
  if (/returned no access_token/.test(err.message)) {
    console.error(`Unexpected token response — possible proxy interception: ${err.message}`);
    // investigate the network path; do not coerce undefined into a bearer
  } else throw err;
}

Prevention

When it happens

Trigger: Google returns 200 but the JSON lacks `access_token` — e.g. an unexpected envelope, a response from an intercepting proxy, or an API anomaly. `if (!data.access_token)` fires after `res.json()`.

Common situations: A captive portal / corporate proxy returning a 200 HTML-or-JSON page that is not the real token response; a Google API transient anomaly; a misconfigured TOKEN_URL pointing at the wrong endpoint; response parsing returning an unexpected shape.

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/d819801dab7eb5a1. Report an issue: GitHub.