ruby-on-railsgoogle-cloud-platformgoogle-cloud-functionsgoogle-auth-library

How do I authenticate a service account with a google cloud function?


I've been trying to set up a google cloud function (2nd gen with http trigger) and can't seem to get past a 401 unauthorized. I got it working using an identity token I was generating via the gcloud cli but I need to use a service account to avoid the identity token's 1 hr expiration time.

I have set cloud function invoker and cloud run invoker permissions on the service account as well as connected the service account to the cloud function directly. Despite what I do, I cannot get it to authorize using the service account.

I'm trying to authenticate from a Rails app using the googleauth gem. Below is my service.

# app/services/cloud_function_service.rb

class CloudFunctionService
  # Use the credentials and URL from Rails credentials or environment
  def self.api_request(function_type, body_data)
    new.api_request(function_type, body_data)
  end

  def api_request(function_type, body_data)
    access_token = fetch_access_token
    uri = Rails.application.credentials.CLOUD_FUNCTION_URL

    body = {
      'function_type': function_type,
      'body': body_data
    }.to_json

    headers = {
      'Authorization' => "Bearer #{access_token}",
      'Content-Type' => 'application/json'
    }

    # Make the POST request with headers and body
    result = HTTParty.post(uri, body: body, headers: headers)
    JSON.parse(result.body)
  rescue => e
    Rails.logger.error "Failed to call cloud function: #{e}"
    nil
  end

  private

  def fetch_access_token
    authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
      json_key_io: File.open('./google-service-account-keys.json'),
      scope: 'https://www.googleapis.com/auth/cloud-platform'
    )
    response = authorizer.fetch_access_token!
    response['access_token']
  end
end


Solution

  • Answering my own question here. There were actually two issues that may be helpful for anyone invoking a cloud function from a Ruby on Rails app.

    First, I misunderstood the usage of access token vs. identity token but you do actually need both. The access token is required to hit google API endpoints (which cloud function endpoints are not considered a part of). The identity token is needed to hit cloud function endpoints. But... you use the access token to get the identity token.

    Second, unlike other language libraries for Google Auth the Ruby library doesn't provide a method to get the identity token. It only provides a method to get the access token. You need to build your own api handler for the identity token endpoint.

    Basically, the flow looks like this:

    1. Use GoogleAuth to get the access token for a service account.
    2. Provide that access token in the request to get the identity token (since you're hitting a google api here you use the access token to get the id token)
    3. Hit your cloud function url with the identity token as a bearer token in the auth header.

    Here is my final code that works!

    class CloudFunctionService
      def self.api_request(function_type, body_data)
        new.api_request(function_type, body_data)
      end
    
      def api_request(function_type, body_data)
        access_token = fetch_access_token
        service_account_email = _your_gcp_service_account_email_
        target_audience = _your_cloud_function_uri_
        identity_token = fetch_identity_token(service_account_email, target_audience, access_token)
        uri = _your_cloud_function_uri_
    
        body = {
          # Your body data
        }.to_json
    
        headers = {
          'Authorization' => "Bearer #{identity_token}",
          'Content-Type' => 'application/json'
        }
    
        result = HTTParty.post(uri, body: body, headers: headers)
        JSON.parse(result.body)
      rescue => e
        Rails.logger.error "Failed to call cloud function: #{e}"
        nil
      end
    
      private
    
      def fetch_access_token
        decoded_json_key = Base64.decode64(_your_base64_encoded_json_key_file)
        json_key_io = StringIO.new(decoded_json_key)
        authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
          json_key_io: json_key_io,
          scope: 'https://www.googleapis.com/auth/cloud-platform'
        )
    
        authorizer.fetch_access_token!['access_token']
      end
    
      def fetch_identity_token(service_account_email, target_audience, access_token)
        url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{service_account_email}:generateIdToken"
        body = {
          audience: target_audience,
          includeEmail: true
        }.to_json
        response = HTTParty.post(
          url,
          body: body,
          headers: {
            'Content-Type' => 'application/json',
            'Authorization' => "Bearer #{access_token}"
          }
        )
        if response.code == 200
          JSON.parse(response.body)['token']
        else
          raise "Failed to fetch identity token: #{response.body}"
        end
      end
    end