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

使用Google Calendar API遇Signet授权错误(invalid_grant)求助

问题分析与解决方案

核心错误原因

  1. 授权码(code)重复使用:OAuth 2.0的授权码是一次性的,且有效期仅约10分钟。你通过Devise Omniauth登录时,Omniauth已经用该code换取过一次token,再次使用同一个code会直接触发invalid_grant错误。
  2. Redirect URI不匹配:你在client_options中配置的redirect_uri指向Devise的回调地址,但Calendar控制器的callback路由是独立的,URI不匹配会导致谷歌服务器验证授权请求失败。
  3. 错误存储code而非token:你将授权code存在用户模型中,而非直接存储谷歌返回的access_token和refresh_token,这完全违背了OAuth的流程逻辑。

修复步骤

1. 调整Devise Omniauth配置(获取Calendar权限)

修改config/initializers/devise.rb,添加Calendar权限并开启离线访问(用于获取refresh_token):

config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'],
  scope: 'email,profile,calendar.readonly',
  access_type: 'offline', # 必须添加才能获取refresh_token
  prompt: 'select_account consent' # 确保首次授权时用户同意离线访问

2. 修改用户模型的Omniauth回调逻辑

直接存储谷歌返回的token信息,而非code:

def self.from_omniauth(auth)
  where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
    user.email = auth.info.email
    user.password = Devise.friendly_token[0, 20]
    # 存储谷歌授权token
    user.google_access_token = auth.credentials.token
    user.google_refresh_token = auth.credentials.refresh_token
    user.google_token_expires_at = Time.at(auth.credentials.expires_at)
  end
end

3. 重构Calendar控制器逻辑

删除错误的code复用逻辑,直接使用用户存储的token,并处理token过期自动刷新:

class Webhooks::CalendarController < ApplicationController
  before_action :authenticate_user!
  skip_after_action :verify_authorized

  # 如果需要单独的Calendar授权流程(用户已登录但未授权Calendar),保留此方法
  def redirect
    client = Signet::OAuth2::Client.new(client_options)
    redirect_to client.authorization_uri.to_s, allow_other_host: true
  end

  def callback
    client = Signet::OAuth2::Client.new(client_options)
    client.code = params[:code] # 使用当前请求的一次性code,而非用户存储的旧code
    response = client.fetch_access_token!

    # 将新token存入用户模型
    current_user.update!(
      google_access_token: response['access_token'],
      google_refresh_token: response['refresh_token'],
      google_token_expires_at: Time.now + response['expires_in'].seconds
    )

    redirect_to calendar_url
  end

  def calendars
    client = Signet::OAuth2::Client.new(client_options)
    client.access_token = current_user.google_access_token
    client.refresh_token = current_user.google_refresh_token
    client.expires_at = current_user.google_token_expires_at.to_i

    # 自动刷新过期的token
    if client.expired?
      response = client.refresh_access_token!
      current_user.update!(
        google_access_token: response['access_token'],
        google_token_expires_at: Time.now + response['expires_in'].seconds
      )
      client.access_token = response['access_token']
    end

    service = Google::Apis::CalendarV3::CalendarService.new
    service.authorization = client
    @calendar_list = service.list_calendar_lists
  end

  private

  def client_options
    {
      client_id: ENV.fetch("GOOGLE_CLIENT_ID", ""),
      client_secret: ENV.fetch("GOOGLE_CLIENT_SECRET", ""),
      authorization_uri: "https://accounts.google.com/o/oauth2/auth",
      token_credential_uri: "https://accounts.google.com/o/oauth2/token",
      scope: Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY,
      redirect_uri: webhooks_callback_url # 使用Rails路由助手确保URI与控制台配置一致
    }
  end
end

4. 开发者控制台配置检查

确保OAuth 2.0客户端ID的授权重定向URI包含Calendar控制器的回调地址(例如http://localhost:3000/webhooks/callback),与client_options中的redirect_uri完全一致。

内容的提问来源于stack exchange,提问作者maxagno3

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.11 10:42:26