You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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分钟以内。

代码侧调整

  1. 调整逻辑分支优先级:优先判断是否存在有效refresh_token,避免无意义的auth code重复兑换。
  2. 新增auth code一次性消费校验:兑换前先判断该code是否已经被使用,已兑换的code直接拦截,不再发起重复请求。
  3. 修正redirect_uri参数:Android离线授权场景下,auth code兑换请求的redirect_uri固定传urn:ietf:wg:oauth:2.0:oob,禁止传空值。
  4. 新增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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 19:30:10