Google Fit离线访问刷新access token返回invalid_grant错误
Google Fit 同步时 OAuth 接口返回 invalid_grant 报错修复方案
问题现象
对接Google Fit离线访问能力时,少数账号在获取/刷新access token环节,调用token接口返回异常:
400 Bad Request
响应内容:{ "error" : "invalid_grant", "error_description" : "Bad Request" }
该报错仅在少量账号触发,可排除全局配置错误,基本为边缘场景下的token逻辑漏洞、凭据失效或参数不匹配导致。
常见触发原因
- 授权码重复消费:Google颁发的单次serverAuthCode有效期仅10分钟,且仅支持一次兑换,重复发起兑换请求会直接触发该错误。如果客户端存在网络重试、多线程提交逻辑,很容易出现同一个auth code被多次调用的情况。
- 刷新令牌(refresh_token)失效:以下场景会导致已存储的refresh_token作废:
- 用户在Google账号安全中心手动撤销了应用的访问授权
- refresh_token连续6个月未使用会被Google自动回收
- 应用调整过OAuth申请的权限范围,旧版本授权颁发的token自动失效
- 应用处于测试发布状态时,所有测试用户的授权token有效期仅7天,超期自动失效
- 客户端发起授权时未正确配置离线访问参数,没有拿到新的refresh_token,覆盖了本地存储的旧有效token
- 参数配置错误:构造token兑换请求时redirect_uri传值不符合要求、服务端使用了Android类型的OAuth客户端凭据(而非Web类型凭据)发起兑换,也会在部分账号场景下触发报错。
- 时间偏差:服务端系统时间与Google标准时间偏差超过5分钟时,token签名、有效期校验会失败,触发该错误。
修复方案
配置侧调整
- 客户端授权参数补全:Android端发起登录请求时,
requestServerAuthCode方法第二个参数必须传true以开启离线访问模式,确保首次授权时能拿到refresh_token;如果用户之前授权过但本地没有存储refresh_token,需要追加prompt=consent参数强制弹出授权页,重新获取有效refresh_token。 - 凭据类型校验:服务端兑换token使用的clientId、clientSecret必须是Google Cloud控制台中「Web应用」类型的客户端凭据,Android类型的客户端凭据不提供secret,无法用于服务端token兑换。
- 发布状态检查:如果应用已经面向普通用户开放,需要将Google Cloud中的OAuth同意屏幕从「测试状态」切换为「发布状态」,避免测试用户token7天自动过期的问题。
- 时间同步:服务端配置NTP时间同步服务,保证系统时间与标准UTC时间差控制在1分钟以内。
代码侧调整
- 调整逻辑分支优先级:优先判断是否存在有效refresh_token,避免无意义的auth code重复兑换。
- 新增auth code一次性消费校验:兑换前先判断该code是否已经被使用,已兑换的code直接拦截,不再发起重复请求。
- 修正redirect_uri参数:Android离线授权场景下,auth code兑换请求的redirect_uri固定传
urn:ietf:wg:oauth:2.0:oob,禁止传空值。 - 新增token失效兜底逻辑:捕获刷新token时返回的invalid_grant错误,直接清空本地存储的失效refresh_token和access_token,标记用户需要重新授权,禁止反复用失效token发起重试。
修正后的核心实现代码如下:
public String getAccessToken(ThirdPartyAuthDto r) throws Exception { GoogleClientSecrets clientSecrets = getGoogleClient(); // 优先使用已存储的有效refresh_token刷新access_token if (r.getRefreshToken() != null && !r.getRefreshToken().isBlank()) { // 现有access_token未过期直接返回 if (r.getLastRequestSuccessDate() != null && new Date().before(new Date(r.getLastRequestSuccessDate().getTime() + r.getExpiresInSeconds() * 1000))) { return r.getAccessToken(); } try { TokenResponse tokenResponse = refreshAccessToken(r.getRefreshToken(), clientSecrets.getDetails().getClientId(), clientSecrets.getDetails().getClientSecret()); r.setAccessToken(tokenResponse.getAccessToken()); // 提前5分钟标记token过期,避免临界点请求失败 r.setLastRequestSuccessDate(new Date(new Date().getTime() - (5 * 60000))); r.setExpiresInSeconds(tokenResponse.getExpiresInSeconds()); thirdPartyRepository.save(r); return tokenResponse.getAccessToken(); } catch (HttpResponseException e) { // 解析Google返回的错误内容 JacksonFactory jacksonFactory = new JacksonFactory(); GoogleTokenErrorDetails errorDetails = jacksonFactory.fromString( e.getContent(), GoogleTokenErrorDetails.class); if ("invalid_grant".equals(errorDetails.getError())) { // 清空失效凭据,引导用户重新授权 r.setRefreshToken(null); r.setAccessToken(null); thirdPartyRepository.save(r); throw new Exception("Google账号授权已失效,请重新绑定"); } throw e; } } // 无有效refresh_token时,校验授权码合法性后发起兑换 if (r.getServerAuthCode() == null || r.getServerAuthCode().isBlank()) { throw new Exception("缺少授权信息,请重新发起Google账号绑定"); } // 校验授权码是否已经被消费,避免重复兑换(可基于缓存+用户ID实现,过期时间设15分钟即可) if (checkAuthCodeUsed(r.getUserId(), r.getServerAuthCode())) { throw new Exception("授权码已失效,请重新发起绑定"); } GoogleTokenResponse activeUser = new GoogleAuthorizationCodeTokenRequest( new NetHttpTransport(), new JacksonFactory(), "https://oauth2.googleapis.com/token", clientSecrets.getDetails().getClientId(), clientSecrets.getDetails().getClientSecret(), r.getServerAuthCode(), // Android离线授权场景固定传该值 "urn:ietf:wg:oauth:2.0:oob" ).execute(); r.setAccessToken(activeUser.getAccessToken()); r.setTokenType(activeUser.getTokenType()); r.setExpiresInSeconds(activeUser.getExpiresInSeconds()); r.setRefreshToken(activeUser.getRefreshToken()); r.setScope(activeUser.getScope()); r.setLastRequestDate(new Date()); r.setLastRequestSuccessDate(new Date(new Date().getTime() - (5 * 60000))); // 标记授权码已消费 markAuthCodeUsed(r.getUserId(), r.getServerAuthCode()); thirdPartyRepository.save(r); return activeUser.getAccessToken(); } public TokenResponse refreshAccessToken(String refreshToken, String clientId, String clientSecret) throws IOException { return new GoogleRefreshTokenRequest( new NetHttpTransport(), new JacksonFactory(), refreshToken, clientId, clientSecret) .execute(); }
内容的提问来源于stack exchange,提问作者SONU KUMAR
相关产品推荐
相关产品推荐

