tl;dr

Recently I’ve been working on a Ruby on Rails project, which requires access to Google Calendars of multiple users. I decided to use Sorcery for registering users via Google and receiving access to their calendars. Unfortunately, it turned out that synchronizing calendars’ data was slightly more complicated than I initially anticipated. Here’s how I did it.

Sorcery

As always, I had to start by adding gem to the Gemfile:

  gem 'sorcery'

and running bundle install. Then, I ran generator for external submodule added by Sorcery:

rails g sorcery:install external

It will create the initializer file, the User model, unit test stubs, and the default migration for User and Authentication tables (the last one will store Google UID - or any other supported provider you decide to use).

class SorceryExternal < ActiveRecord::Migration
  def change
    create_table :authentications do |t|
      t.integer :user_id, :null => false
      t.string :provider, :uid, :null => false

      t.timestamps
    end
  end
end

After running migration rake db:migrate, I created model for Authentication:

rails g model Authentication --migration=false

to associate User with it:

class User < ActiveRecord::Base
  authenticates_with_sorcery! do |config|
    config.authentications_class = Authentication
  end

  has_many :authentications, :dependent => :destroy
  accepts_nested_attributes_for :authentications
end

class Authentication < ActiveRecord::Base
  belongs_to :user
end

Next, I added Google settings to Sorcery’s configuration file:

# config/initializers/sorcery.rb

Rails.application.config.sorcery.submodules = [:external]

Rails.application.config.sorcery.configure do |config|
  ...
  config.external_providers = [:google]

# add this file to .gitignore BEFORE putting any secret keys in here

  config.google.key = "<your key here>"
  config.google.secret = "<your key here>"
  config.google.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=google"
  config.google.user_info_mapping = {
    :email => "email",
    :first_name => "given_name",
    :last_name => "family_name"
  }

  …

  config.user_config do |user|
  ...

    user.authentications_class = Authentication
    ...

  end
  ...
  config.user_class = "User"
end

To get Google’s key and secret, you will have to register your app using Google Developer Console.

The user_info_mapping converts the user info from the provider into the attributes that your user has – I used it to map email, first and last names of the registering user.

To connect your app to Google, and allow users to log in, you add this link to your view

<%= link_to 'Login with Google', auth_at_provider_path(:provider => :google) %>

and create a controller to handle authentication

rails g controller Oauths oauth callback

# app/controllers/oauths_controller.rb

class OauthsController < ApplicationController
  skip_before_filter :require_login

  # sends the user to the provider,
  # and after authorizing there back to the callback url.
  def oauth
    login_at(params[:provider])
  end

  def callback
    provider = params[:provider]
    if @user = login_from(provider)
      redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
    else
      begin
        @user = create_from(provider)
        reset_session # protect from session fixation attack
        auto_login(@user)
        redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
      rescue
        redirect_to root_path, :alert => "Failed to login from #{provider.titleize}!"
      end
    end
  end
end

and add appropriate routes

# config/routes.rb
post "oauth/callback" => "oauths#callback"
get "oauth/callback" => "oauths#callback"
get "oauth/:provider" => "oauths#oauth", :as => :auth_at_provider

In case you integrate Sorcery with Calendar or other Google APIs, you will need to override the default scope. This will result in login page that will ask user if he or she wants to give our application access to those additional APIs:

config.google.scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar"

Various Google APIs allow you to integrate with Sorcery and Rails the same way as Google Calendar. In case you are interested in some API, you can check if’s on documentation page which specifies one or more scopes you can use, and pick the ones you are interested in. For example, if you want to integrate with Google Drive API, you could pick one of the following scopes as listed by Drive API documentation.

Google Calendar API

Now that the registration was finished, I wanted to receive events from user’s calendar - to do it I had to have access to Google Calendar API. I added another gem to the Gemfile:

  gem 'google-api-client', :require => 'google/api_client'

and ran bundle install.

Using tokens received from Sorcery I could get events using API

GET https://www.googleapis.com/calendar/v3/calendars/calendarId/events

but first I needed to have calendarId. I added calendar_id to my User, as later I wanted to keep synchronizing events using cron job

rails g migration AddGoogleCalendarIdToUsers google_calendar_id:string

and then using API I sent request to get a list of calendars:

client = Google::APIClient.new
client.authorization.access_token = <access_token_from_sorcery>
service = client.discovered_api('calendar', 'v3')
result = client.execute(api_method: service.calendar_list.list).data.items

After receiving the list, I saved id of the one I wanted to synchronize periodically and then I made another request - this time to get all events of the saved calendar:

result = client.execute(api_method: service.events.list, parameters: {calendarId: user.google_calendar_id})

Google API allows to send many extra parameters but I only wanted to mention one which is syncToken. This token is obtained from the nextSyncToken field returned from the last list request. By using it, your result will only contain entries that have changed since then. If the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.

And now comes the troblesome part - to connect to Google you need valid access token and the one received form Sorcery obviously expires after some time. Unfortunately Sorcery doesn’t return reset token which can be used to generate new access token so basically I was stuck on creating a cron job as without access token I couldn’t receive list of calendar’s events.

The solution was to create a new client id in my app in Google Developer Console – this time for service account. By doing so I received a private key and email which allowed me to generate new access token without having a reset token.

client = Google::APIClient.new(application_name: <name>, application_version: <version>)
key = OpenSSL::PKey::RSA.new <google_service_private_key>, 'notasecret'
asserter = Google::APIClient::JWTAsserter.new(
  <google_service_email>,
  ['https://www.googleapis.com/auth/calendar'],
  key
)
client.authorization = asserter.authorize
service = client.discovered_api('calendar', 'v3')

User.all.each do |user|
    result = client.execute(api_method: service.events.list, parameters: {calendarId: user.google_calendar_id})
    ...
end

But even though I had a vaild token, when I ran rake task for multiple users I got 404 for most of them. Why? The problem was I needed to share their calendars with a <google_service_email> so before getting a list of calendars I sent request to API that does that:

rule = {scope: {'type' => 'user', value' => <google_service_email>}, 'role' => 'writer'}
client.execute(api_method: service.acl.insert, parameters: {calendarId: user.google_calendar_id}, body: JSON.dump(rule), headers: {'Content-Type' => 'application/json'})

after that I could get all events for all registered users :)

Summary

As you can see, overall it wasn’t hard to do. The only tricky part, was to figure out how to receive an access token without reset token. The rest could be easily solved by reading the documentation. I hope that this post might come in handy and save some time for somebody :)

And if you have any experience with such integration yourself, please share it with me :)

Post by Dominika Mips

Dominika was a long-time and excellent employee, who sadly left AmberBit to find her luck in Silicon Valley. We hope she returns to us one day :).