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 :).