{"record":{"id":"60a3f3e9ad32ef97","repo":"different-ai/openwork","slug":"google-oauth-refresh-did-not-return-an-access-toke","errorCode":null,"errorMessage":"Google OAuth refresh did not return an access token.","messagePattern":"Google OAuth refresh did not return an access token\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/server/src/extensions/google-workspace.ts","lineNumber":610,"sourceCode":"}\n\nasync function refreshGoogleWorkspaceVault(record: Record<string, unknown>) {\n  const token = isRecord(record.token) ? record.token : null;\n  const expiresAt = Number(token?.expiresAt ?? 0);\n  const accessToken = typeof token?.accessToken === \"string\" ? token.accessToken : \"\";\n  const refreshToken = typeof token?.refreshToken === \"string\" ? token.refreshToken : \"\";\n  if (accessToken && expiresAt > Date.now() + 60_000) return record;\n  if (!refreshToken) throw new Error(\"Google Workspace refresh token is missing. Reconnect Google Workspace.\");\n  const { clientId, clientSecret, tokenBrokerUrl, missing } = googleWorkspaceCredentials();\n  if (missing.length > 0) throw new Error(`Missing Google OAuth configuration: ${missing.join(\", \")}`);\n  const refreshed = tokenBrokerUrl\n    ? await fetchGoogleWorkspaceTokenBrokerJson(tokenBrokerUrl, { grantType: \"refresh_token\", provider: GOOGLE_WORKSPACE_EXTENSION_ID, clientId, refreshToken })\n    : await fetchGoogleJson(\"https://oauth2.googleapis.com/token\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n      body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: \"refresh_token\", refresh_token: refreshToken }),\n    });\n  if (!isRecord(refreshed) || typeof refreshed.access_token !== \"string\") throw new Error(\"Google OAuth refresh did not return an access token.\");\n  const next = {\n    ...record,\n    scopes: typeof refreshed.scope === \"string\" ? refreshed.scope.split(/\\s+/).filter(Boolean) : record.scopes,\n    token: {\n      accessToken: refreshed.access_token,\n      refreshToken: typeof refreshed.refresh_token === \"string\" ? refreshed.refresh_token : refreshToken,\n      expiresAt: Date.now() + Number(refreshed.expires_in ?? 3600) * 1000,\n    },\n    updatedAt: new Date().toISOString(),\n  };\n  return next;\n}\n\nasync function googleWorkspaceAccessToken(config: ServerConfig): Promise<{ record: Record<string, unknown>; accessToken: string }> {\n  const vault = await readGoogleWorkspaceVault(config);\n  const record = googleWorkspacePrimaryRecord(vault);\n  if (!record) throw new ApiError(400, \"google_workspace_not_connected\", \"Connect Google Workspace in OpenWork Settings to use this tool.\");\n  const refreshed = await refreshGoogleWorkspaceVault(record);","sourceCodeStart":592,"sourceCodeEnd":628,"githubUrl":"https://github.com/different-ai/openwork/blob/2b7df46e8ae1517d64c896c7793d2d52ec845669/apps/server/src/extensions/google-workspace.ts#L592-L628","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nPOST /token => {\"error\":\"invalid_grant\",\"error_description\":\"Token has been expired or revoked.\"}\n// throws: Google OAuth refresh did not return an access token.\n\n// after: reconnect the account\n{ \"token\": { \"accessToken\": \"ya29-new\", \"refreshToken\": \"1//0g-new\", ... } }","handlingStrategy":"retry","validationCode":"const res = await fetch(\"https://oauth2.googleapis.com/token\", init);\nconst body = await res.json();\nif (typeof body?.access_token !== \"string\") {\n  if (body?.error === \"invalid_grant\") await forceReconnect(); // retry is futile\n  else await retryWithBackoff(); // transient broker/5xx failure\n}","typeGuard":"function isTokenRefreshResponse(v: unknown): v is { access_token: string; refresh_token?: string; scope?: string } {\n  return typeof v === \"object\" && v !== null && typeof (v as { access_token?: unknown }).access_token === \"string\";\n}","tryCatchPattern":"try {\n  const record = await refreshGoogleWorkspaceVault(record);\n} catch (err) {\n  if (err instanceof Error && err.message === \"Google OAuth refresh did not return an access token.\") {\n    await invalidateAccountAndReconnect(); // refresh token likely revoked/expired; do not blind-retry\n    return;\n  }\n  throw err;\n}","preventionTips":["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"],"tags":["oauth","refresh-token","invalid-grant","google-workspace","network"],"backgroundTag":"refresh-token-invalid","analyzedSha":"2b7df46e8ae1517d64c896c7793d2d52ec845669","analyzedAt":"2026-09-01T07:59:23.713Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}