Skip to content

Instantly share code, notes, and snippets.

@jesster2k10
Last active December 4, 2024 05:20
Show Gist options
  • Save jesster2k10/ff96b5adbce72abae5fc603bd17c1843 to your computer and use it in GitHub Desktop.
Save jesster2k10/ff96b5adbce72abae5fc603bd17c1843 to your computer and use it in GitHub Desktop.
Rails API Social Login

Rails API-Only Social Login

This is another piece of code I've extrapolated from a Ruby on Rails project I'm currently working on. The code implmenets social login with a RoR API-based application, targeted at API clients.

The setup does not involve any browser-redirects or sessions as you would have to use working with Omniauth. Instead, what it does is takes an access_token generated on client-side SDKs, retireves user info from the access token and creates a new user and Identity in the database.

This setup works with native applications as described in the Google iOS Sign In Docs (see Authenticating with a backend server) A quote from that page pretty much sums up how this works:

After you have verified the token, check if the user is already in your user database. If so, establish an authenticated session for the user. If the user isn't yet in your user database, create a new user record from the information in the ID token payload, and establish a session for the user. You can prompt the user for any additional profile information you require when you detect a newly created user in your app.

The basic flow of the login is:

  • You sign in using the respective native sdks on your mobile/js clients
  • An access_token/id_token is retireved from the mobile SDKs
  • A request is made to your Rails Application POST /identities/:provider with the token
  • The server then fetches the user data from the token after validating it with the provider
  • The server then creates a user profile & Identity based on that information.

In comparison, if you were using Omniauth you would have to:

  • Open up a web view in your mobile app linking to your backend
  • Your user is redirected from your backend to the external provider
  • The user signs in with the external provider
  • The user is redirected back to your backend
  • The backend generates a user account
  • Then it generates an auth token (assuming you're using JWT auth)
  • The backend redirects the user back to the native app with the access token.

So there's an advantage to using this setup, it's a lot easier to work with a single JSON API endpoint than it is to work with a bunch of redirects in a web view.

However, if you are using a traditional Rails Application (not a rails api), you should totally go with Omniauth and save all the hastle.

The code is quite modular so it will be extemely easy to add support for another login provider if needed. It's a matter of creating another Provider::Base super class and including the provider name in your Identity.providers array.

Basic Structure

There are quite a lot of files in this, each with different roles.

base_provider.rb

An abstract class that serves as a base for all the different login providers making it easy to implement new providers if needed. Provides common extractions for methods.

facebook_provider.rb

A provider class for handling login with facebook.

Note: The fields requested must match those requested on the native side otherwise you're going to get a run-time error becuase the granted acccess token won't have adequate permissions

google_provider.rb

A provider class for handling login with google. You need to provide an array of client_ids for the different clients you will have needing to sign in e.g. iOS App, Android App, React App, Windows App etc.

In this example, I only have one (ios_client_id)

provider_credentials.rb

Just an object to handle the credentials (access token/refresh token)

provider_info.rb

This provides a common interface for the User Information retrieved from the external APIs. Similar to the Omniauth env['auth']

identity.rb

So each user has what I call an Identity which is just an external login. If you want your app to only support one external login e.g. facebook only, this identities table wouldn't be needed as you could just store the uid/provider/access_token on the user model.

The identities table is structured like so:

    create_table :identities do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :provider, null: false
      t.string :uid, null: false
      t.string :access_token
      t.string :refresh_token
      t.jsonb :auth_hash, default: {}

      t.timestamps
    end
    
    add_index :identities, %i[provider uid], unique: true

Dependencies

Right now, I've implemented only two providers - Google and Facebook auth. The dependencies that I'm using are

gem 'google-id-token', git: 'https://github.com/google/google-id-token.git'
gem 'koala'

Note: Make sure to install the google-id-token gem from the repo (as of 26th April 2020) in order to support passing an array of client_ids

Specs/Tests

The tests can be found here

module External
# A base class for handling external logins
module Provider
class Base
attr_reader :access_token, :refresh_token, :client
def initialize(access_token, refresh_token = nil)
handle_missing_access_token unless access_token.present?
@access_token = access_token
@refresh_token = refresh_token
@client = retrieve_client
end
def self.for(provider)
class_name = provider.to_s.classify
"External::Provider::#{class_name}".safe_constantize
end
# The name of the provider
def self.name
:base
end
# The user's info
def info
handle_unimplmeneted_method
end
# The user's credentials
def credentials
ProviderCredentials.new do |c|
c.access_token = access_token
c.refresh_token = refresh_token
end
end
# The raw info returned from the hash
def raw_info
@raw_info ||= retrieve_user_info.deep_symbolize_keys
end
# Returns the raw info hash from the client
def retrieve_user_info
handle_unimplmeneted_method
end
def retrieve_client
handle_unimplmeneted_method
end
private
def handle_missing_access_token
raise Errors::Domain, status: 422, message: 'missing token'
end
def handle_unimplmeneted_method(method = :method)
raise StandardError, "Expected super class to override #{method}"
end
end
end
end
module External
module Provider
class Facebook < Base
def info
ProviderInfo.new do |i|
i.provider = External::Provider::Facebook.name
i.date_format = '%m/%d/%Y'
i.uid = raw_info[:id]
i.email = raw_info[:email]
i.birthday = raw_info[:birthday]
i.first_name = raw_info[:first_name]
i.last_name = raw_info[:last_name]
i.name = raw_info[:name]
end
end
def retrieve_client
Koala::Facebook::API.new access_token
end
def retrieve_user_info
client.get_object(
:me,
fields: %w[id email birthday first_name last_name]
)
end
def self.name
:facebook
end
end
end
end
module External
module Provider
class Google < Base
def info
ProviderInfo.new do |i|
i.provider = External::Provider::Google.name
i.avatar = raw_info[:picture]
i.first_name = raw_info[:given_name]
i.last_name = raw_info[:family_name]
i.email = raw_info[:email]
i.name = raw_info[:name]
i.uid = raw_info[:sub]
end
end
def retrieve_client
GoogleIDToken::Validator.new
end
def retrieve_user_info
client.check(
id_token,
client_ids
)
end
def self.name
:google
end
private
# The different clients your app needs to authenticate agains
# E.g. iOS, Android, Web etc.
def client_ids
[Rails.application.credentials.google[:ios_client_id]]
end
alias id_token access_token
end
end
end
class Api::V1::IdentitiesController < ApplicationController
before_action :authenticate_for_identity!
attr_reader :provider, :identity, :user, :access_token, :refresh_token
# POST /identities/:provider
def provider_create
@provider = params[:provider]
# We want to make sure we support the provider
if Identity.valid_provider?(provider)
set_identity
return unless set_user
# If the identity exists, we want to update it
if identity.present?
identity.update identity_params
# Create a new identity for this user
else
user.identities.create identity_params
end
# Finally we want to create a new auth token pair for the user
issue_tokens
# Render the user object and the auth tokens
render_json data: {
token: { access_token: access_token, refresh_token: refresh_token },
user: UserBlueprint.render_as_json(user)
}, status: :created
else
# Render an error saying the provider is not supported
render_unsupported_provider_error
end
end
def set_identity
@identity = Identity.where(provider: provider, uid: external.info.uid).first
end
def set_user
# The user is already logged in (access_token present in request)
# We want to set the user to the current user
if current_user.present?
@user = current_user
# The user has signed in with this provider before
# Return the existing account
elsif identity.present?
@user = identity.user
# There is an existing user account with the same email & the user is not
# logged in. We don't want to create an account for safety.
# Tell the user to sign in to their account and link it there
elsif User.where(email: external.info.email).any?
render_existing_account_error provider: provider
return false
# The user has never signed in before, we want to create a new account from
# the external info object
else
@user = User.create_from_provider_info(external.info)
end
true
end
# We want to update the identity object with the new uid and provider.
# Storing the ccess_token or refresh_token is optional since that
# can be delt with on the native clients.
def identity_params
{
provider: external.info.provider,
uid: external.info.uid,
access_token: external.credentials.access_token,
refresh_token: external.credentials.refresh_token,
auth_hash: external.info.to_h
}
end
def issue_tokens
@access_token, @refresh_token = Jwt::Issuer.call user
response.set_header 'Access-token', access_token
response.set_header 'Refresh-token', refresh_token
end
private
# Returns the External::Provider object for the current provider
# We can access the user info object and raw_info hash from here
def external
@external ||= External::Provider::Base
.for(provider)
.new(create_params[:token])
end
def create_params
params.require(:data).permit(:token)
end
end
class Identity < ApplicationRecord
belongs_to :user
PROVIDERS = %w[facebook google].freeze
PROVIDERS.each do |provider|
scope provider, -> { where(provider: provider) }
end
validates :uid, uniqueness: { scope: :provider }, presence: true
validates :provider, inclusion: {
in: PROVIDERS,
message: 'this provider is not supported'
}, presence: true
def self.providers
PROVIDERS
end
def self.valid_provider?(provider)
providers.include?(provider.to_s)
end
end
module External
class ProviderCredentials
attr_accessor :access_token, :refresh_token, :expires, :expires_at
def initialize
yield self if block_given?
end
def to_h
{
access_token: access_token,
refresh_token: refresh_token,
expires: expires,
expires_at: expires_at
}
end
alias hash to_h
end
end
module External
class ProviderInfo
attr_accessor :uid, :email, :username, :date_format, :avatar, :provider
attr_writer :birthday, :first_name, :last_name, :name
def initialize
yield self if block_given?
end
def first_name
raw_name = @first_name
if raw_name.present?
raw_name
elsif @name.present?
split_name = @name.split(' ')
if split_name.length > 1
@name.split(' ')[0..-2].join(' ')
else
split_name.first
end
end
end
def last_name
raw_name = @last_name
if raw_name.present?
raw_name
elsif @name.present?
split_name = @name.split(' ')
return split_name if split_name.length > 1
end
end
def birthday
raw_value = @birthday
if raw_value.is_a?(String)
if date_format.present?
Date.strptime raw_value, date_format
else
Date.parse raw_value
end
elsif raw_value.is_a?(Integer)
Time.at(raw_value).to_date
else
raw_value
end
end
def name
raw_name = @name
if raw_name.present?
raw_name
elsif @first_name.present? && @last_name.present?
"#{@first_name} #{@last_name}"
elsif @first_name.present?
@first_name
end
end
def to_h
{
uid: uid,
email: email,
username: username,
birthday: birthday,
first_name: first_name,
last_name: last_name,
name: name
}
end
alias hash to_h
end
end
@joelcahalan
Copy link

This looks quite useful! Thanks for posting. One question: where is the controller before actionauthenticate_for_identity! method implemented?

@jesster2k10
Copy link
Author

Hey sorry for the late reply. I’m pretty sure all that does is get the access token from the request header and stores it in an instance variable without raising an error. It’s basically an authenticate! function that works the same but won’t throw an error if no token is found (since you obviously don’t want that to happen)

@jessezach
Copy link

@jesster2k10 Thank you for this!! This is so helpful.

@iamsamkh
Copy link

iamsamkh commented Apr 29, 2023

@jesster2k10 What is the purpose of issue_tokens and how is JWT::Issuer handling this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment