ruby-on-railsdoorkeeper

Invalid scope error in Doorkeeper assertion flow


This is a follow-up question to InvalidSegmentEncoding in trying to implement "Sign in with Google" with doorkeeper-grants_assertion.

I have an endpoint used as a redirect URL for Google OAuth:

  def sign_in_3rd_party_redirect
    case params[:provider]
    when "google"
      # Exchange authorization code for access token
      # https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code
      response = HTTParty.post(
        "https://oauth2.googleapis.com/token",
        headers: { "Content-Type" => "application/x-www-form-urlencoded" },
        body: google_oauth2_params({
          grant_type: "authorization_code",
          code: params[:code],
          client_secret: ENV["GOOGLE_CLIENT_SECRET"],
        }),
      )
      if response.code == 200
        params[:assertion] = response.parsed_response["access_token"]
        sign_in_with_strategy("assertion")
      else
        message = "Unexpected response from the OAuth provider" +
          (Rails.env.development? ? ": #{response.parsed_response || response}" : "")
        render_error(message, status: :bad_request)
      end
    end
  end

  helper_method def google_oauth2_params(options)
    URI.encode_www_form(
      {
        client_id: ENV["GOOGLE_CLIENT_ID"],
        redirect_uri: url_for(action: :sign_in_3rd_party_redirect, provider: "google", only_path: false),
        **options,
      },
    )
  end

  def sign_in_with_strategy(strategy_name)
    strategy = server.token_request(strategy_name)
    auth = strategy.authorize
    Rails.logger.info("sign_in_with_strategy: #{auth.inspect}")

    if auth.is_a?(Doorkeeper::OAuth::ErrorResponse)
      return render_error("Invalid credentials", status: auth.status)
    end

    render json: {
      data: Api::V1::Auth::AccessTokenViewModel.new(auth.token),
    }
  end

In config/initializers/doorkeeper.rb, I have

  resource_owner_from_assertion do
    provider = params[:provider]
    assertion = params[:assertion]
    Rails.logger.info("resource_owner_from_assertion: params=#{params.inspect}")
    if provider && assertion
      # We don't have the same providers for Devise (used in the web app)
      # and Doorkeeper (used in the API). If this changes, or we want to allow both,
      # add https://github.com/doorkeeper-gem/doorkeeper-grants_assertion?tab=readme-ov-file#reuse-devise-configuration
      # to check Devise providers before or instead of this
      case provider
      when "google"
        wrapper = Doorkeeper::GrantsAssertion::OmniAuth.oauth2_wrapper(
          provider: :google, # also tried :google_oauth2, doesn't make a difference
          strategy_class: ::OmniAuth::Strategies::GoogleOauth2,
          client_id: ENV["GOOGLE_CLIENT_ID"],
          client_secret: ENV["GOOGLE_CLIENT_SECRET"],
          # skip_jwt is a workaround for https://github.com/zquestz/omniauth-google-oauth2/issues/435
          client_options: { skip_image_info: true, skip_jwt: true },
          assertion: assertion
        )
        auth = wrapper.auth_hash
        Rails.logger.info("resource_owner_from_assertion: auth=#{auth.inspect}")
      end

      if auth
        user = User.from_omniauth(auth)
        Rails.logger.info("resource_owner_from_assertion: user=#{user.inspect}")
        if user.present?
          user
        end
      end
    end
  end

When I try to sign in with Google, I see and accept the expected consent screens. Then in logs I see (formatted for clarity):

resource_owner_from_assertion: params=#<ActionController::Parameters
{"code"=>{Google OAuth authorization code}, 
 "scope"=>"email openid https://www.googleapis.com/auth/userinfo.email", 
 "authuser"=>"2", 
 "hd"=>{Google account's domain}, 
 "prompt"=>"consent", 
 "controller"=>"api/v1/users_session", 
 "action"=>"sign_in_3rd_party_redirect", 
 "provider"=>"google", 
 "client_id"=>{Doorkeeper client id}, 
 "client_secret"=>{Doorkeeper client secret}, 
 "assertion"=>{Google OAuth access token from the exchange} 
}
permitted: false>

resource_owner_from_assertion: auth=#<OmniAuth::AuthHash 
 credentials=#<OmniAuth::AuthHash 
  expires=false 
  scope="https://www.googleapis.com/auth/userinfo.email openid" 
  token={same as assertion above}
 > 
 extra=#<OmniAuth::AuthHash 
  id_token={same as assertion above} 
  raw_info=#<SnakyHash::StringKeyed 
   email={Google email}
   email_verified=true 
   hd={as above} 
   picture={Google account image URL} 
   sub={same as uid below}
  >
 > 
 info=#<OmniAuth::AuthHash::InfoHash 
  email={as above} 
  email_verified=true 
  image={as above} 
  unverified_email={same email}
 > 
 provider=:google 
 uid={some uid}
>

resource_owner_from_assertion: user={the expected user model}

so this side appears to work fine. Unfortunately, in sign_in_with_strategy I get

sign_in_with_strategy: #<Doorkeeper::OAuth::ErrorResponse:0x00007fe36b901b80 
 @error=#<struct Doorkeeper::OAuth::Error name=:invalid_scope, state=nil>, 
 @redirect_uri=nil, 
 @response_on_fragment=nil
>

I am not certain if the invalid scope error is for Google or for the Doorkeeper server; I think the second because if it was for Google I'd expect the error to happen inside wrapper.auth_hash or even earlier, as it did before. And how can I fix it?

The Doorkeeper application doesn't have any scopes or redirect URI. Could that be the problem?


Solution

  • The issue was scope in the resource_owner_from_assertion: params log. Given this setup, it comes from the redirect URL query parameter and is the scope for Google's access token.

    But in the resource_owner_from_assertion block, scope should be the one we request from Doorkeeper. It's also read from request.parameters, not from params. So in sign_in_3rd_party_redirect, I needed to add

    request.parameters.delete(:scope)