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
- Check the raw refresh response/error (invalid_grant means the refresh token is dead) and reconnect Google Workspace to obtain a fresh grant
- Verify the client_id/client_secret pair still matches the OAuth client that issued the refresh token — a rotated secret invalidates old grants
- If using the token broker, confirm the broker is healthy and returns {access_token, ...} for grantType refresh_token
- 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
- Treat invalid_grant as terminal: reconnect the account rather than retrying in a loop
- Alert on token broker error responses; wrap broker calls with the same access_token validation
- Re-check the OAuth client secret after rotations — mismatched secrets produce token-less refresh responses
- Schedule refreshes well before expiry so a single failure doesn't break live tool calls
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
- Google Workspace refresh token is missing. Reconnect Google
- Missing Google OAuth configuration: ${missing.join(", ")}
- google_workspace_not_connected
- MCP_OAUTH_CREDENTIAL_EXPIRED
- Could not start ${action.connectionName} authorization.
AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01).
Data as JSON: /api/errors/60a3f3e9ad32ef97.
Report an issue: GitHub.