different-ai/openwork · error · Error

Google Workspace refresh token is missing. Reconnect Google

Error message

Google Workspace refresh token is missing. Reconnect Google Workspace.

What it means

refreshGoogleWorkspaceVault() refreshes the stored Google OAuth tokens when the cached access token is expired (or expiring within 60s). If the stored vault record has no refreshToken string, it cannot get a new access token, so it throws immediately. This happens when the original OAuth grant did not return a refresh token (e.g. offline access was not granted or the user re-consented without it) or the vault record is corrupt/partial.

Source

Thrown at apps/server/src/extensions/google-workspace.ts:600

    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      code: input.code,
      code_verifier: input.verifier,
      grant_type: "authorization_code",
      redirect_uri: input.redirectUri,
    }),
  });
}

async function refreshGoogleWorkspaceVault(record: Record<string, unknown>) {
  const token = isRecord(record.token) ? record.token : null;
  const expiresAt = Number(token?.expiresAt ?? 0);
  const accessToken = typeof token?.accessToken === "string" ? token.accessToken : "";
  const refreshToken = typeof token?.refreshToken === "string" ? token.refreshToken : "";
  if (accessToken && expiresAt > Date.now() + 60_000) return record;
  if (!refreshToken) throw new Error("Google Workspace refresh token is missing. Reconnect Google Workspace.");
  const { clientId, clientSecret, tokenBrokerUrl, missing } = googleWorkspaceCredentials();
  if (missing.length > 0) throw new Error(`Missing Google OAuth configuration: ${missing.join(", ")}`);
  const refreshed = tokenBrokerUrl
    ? await fetchGoogleWorkspaceTokenBrokerJson(tokenBrokerUrl, { grantType: "refresh_token", provider: GOOGLE_WORKSPACE_EXTENSION_ID, clientId, refreshToken })
    : await fetchGoogleJson("https://oauth2.googleapis.com/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "refresh_token", refresh_token: refreshToken }),
    });
  if (!isRecord(refreshed) || typeof refreshed.access_token !== "string") throw new Error("Google OAuth refresh did not return an access token.");
  const next = {
    ...record,
    scopes: typeof refreshed.scope === "string" ? refreshed.scope.split(/\s+/).filter(Boolean) : record.scopes,
    token: {
      accessToken: refreshed.access_token,
      refreshToken: typeof refreshed.refresh_token === "string" ? refreshed.refresh_token : refreshToken,
      expiresAt: Date.now() + Number(refreshed.expires_in ?? 3600) * 1000,
    },

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Reconnect Google Workspace via the Settings connect flow so a fresh grant stores a new refresh token
  2. Delete the stale oauth.vault record for the account and redo the OAuth connect from scratch
  3. Verify the vault JSON has token.refreshToken as a string before relying on long-lived sessions
  4. Ensure the connect flow requests offline access / refresh tokens so Google returns one on first consent

Example fix

// before (corrupt record in oauth.vault)
{ "token": { "accessToken": "ya29...", "expiresAt": 1700000000 } }

// after: reconnect via Settings; record now contains
{ "token": { "accessToken": "ya29...", "refreshToken": "1//0g...", "expiresAt": 1700000000 } }
Defensive patterns

Strategy: try-catch

Validate before calling

const vault = JSON.parse(await readFile(vaultPath, "utf8"));
const token = vault?.token;
if (!token || typeof token.refreshToken !== "string" || token.refreshToken.length === 0) {
  await promptReconnect();
}

Type guard

function hasRefreshToken(record: unknown): record is { token: { refreshToken: string } } {
  return typeof record === "object" && record !== null &&
    typeof (record as { token?: unknown }).token === "object" && (record as { token?: unknown }).token !== null &&
    typeof ((record as { token?: { refreshToken?: unknown } }).token).refreshToken === "string";
}

Try / catch

try {
  const { accessToken } = await googleWorkspaceAccessToken(config);
  await callGoogleTool(accessToken);
} catch (err) {
  if (err instanceof Error && err.message.includes("refresh token is missing")) {
    await triggerReconnectFlow(); // surface "Reconnect Google Workspace" to the user
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: Any Google Workspace tool call routed through googleWorkspaceAccessToken() after the stored access token has expired (expiresAt <= Date.now()+60s) while record.token.refreshToken is missing or not a string in the oauth vault.

Common situations: Vault file written by an older extension version without a refresh token; token object corrupted by a partial write; initial grant omitted the refresh token because consent/refresh scope settings changed; user manually edited or truncated the vault file.

Related errors


AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01). Data as JSON: /api/errors/9e3df151db418b62. Report an issue: GitHub.