different-ai/openwork · error · Error

Google OAuth refresh did not return an access token.

Error message

Google OAuth refresh did not return an access token.

What it means

After the refresh request completes, the code validates the response: it must be a JSON object containing a string access_token. If Google (or the token broker) returns an error payload, a non-record, or a token-less 200, this error is thrown. It typically means the refresh token itself was rejected (revoked, expired, scope mismatch) or the broker returned an error shape.

Source

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

}

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,
    },
    updatedAt: new Date().toISOString(),
  };
  return next;
}

async function googleWorkspaceAccessToken(config: ServerConfig): Promise<{ record: Record<string, unknown>; accessToken: string }> {
  const vault = await readGoogleWorkspaceVault(config);
  const record = googleWorkspacePrimaryRecord(vault);
  if (!record) throw new ApiError(400, "google_workspace_not_connected", "Connect Google Workspace in OpenWork Settings to use this tool.");
  const refreshed = await refreshGoogleWorkspaceVault(record);

View on GitHub (pinned to 2b7df46e8a)

Solutions

  1. Check the raw refresh response/error (invalid_grant means the refresh token is dead) and reconnect Google Workspace to obtain a fresh grant
  2. Verify the client_id/client_secret pair still matches the OAuth client that issued the refresh token — a rotated secret invalidates old grants
  3. If using the token broker, confirm the broker is healthy and returns {access_token, ...} for grantType refresh_token
  4. Re-authenticate the account in OpenWork Settings; the stored account record will be replaced with fresh tokens

Example fix

// before
POST /token => {"error":"invalid_grant","error_description":"Token has been expired or revoked."}
// throws: Google OAuth refresh did not return an access token.

// after: reconnect the account
{ "token": { "accessToken": "ya29-new", "refreshToken": "1//0g-new", ... } }
Defensive patterns

Strategy: retry

Validate before calling

const res = await fetch("https://oauth2.googleapis.com/token", init);
const body = await res.json();
if (typeof body?.access_token !== "string") {
  if (body?.error === "invalid_grant") await forceReconnect(); // retry is futile
  else await retryWithBackoff(); // transient broker/5xx failure
}

Type guard

function isTokenRefreshResponse(v: unknown): v is { access_token: string; refresh_token?: string; scope?: string } {
  return typeof v === "object" && v !== null && typeof (v as { access_token?: unknown }).access_token === "string";
}

Try / catch

try {
  const record = await refreshGoogleWorkspaceVault(record);
} catch (err) {
  if (err instanceof Error && err.message === "Google OAuth refresh did not return an access token.") {
    await invalidateAccountAndReconnect(); // refresh token likely revoked/expired; do not blind-retry
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: refreshGoogleWorkspaceVault() receives a response from https://oauth2.googleapis.com/token (or the token broker) that is not a record or lacks refreshed.access_token — e.g. Google returned {"error":"invalid_grant"} or the broker answered with an error JSON.

Common situations: User revoked the app in Google Account security settings (invalid_grant); refresh token expired after 6 months of inactivity or 7 days for apps in testing mode; wrong client secret paired with the refresh token after rotating credentials; token broker outage returning an error body.

Related errors


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