How to do Twitter authentication with devise_token_auth

Acknowledgement

I would like to express my sincere gratitude to @risafj for writing the Guide to devise_token_auth: Simple Authentication in Rails API.

I have implemented an authentication system for my Rails API by following @risafj 's guide, and I would like to add Sign in with Twitter on top of it. I can't find any good tutorial on how to add Twitter authentication using Omniauth (gem) with devise_token_auth. So instead, I decided to make up (hack) my own solution.

Note: Guide is for Linux or MacOS.

User workflow

  1. You click the "Sign in with Twitter" button
  2. You get redirected to a consent screen
  3. You click "Authenticate app"
  4. The page redirects to the actual application

Authentication workflow

Creating a new Twitter application

To make it possible for us to add Twitter authentication to our website, we need to register a new Twitter application on Twitter. This is a necessary step for getting the key and secret code for all communication between our application and Twitter. This is necessary for our application to confirm our identify to Twitter.

We can create a new Twitter application here by clicking Create Project button.

Next, we need to enter our application details.

After we have registered our application, we will get a screen where we can find the key and the secret of our Twitter application, the tab Keys and Access Tokens. Later we will need to use these keys, so save them in your computer for now.

Once this is done, you can use the left side menu bar to navigate to your project to config User authentication settings.

Pick the v1 API

On the bottom of the page we can find the Callback URI / Redirect URL, and Website URL. Fill them up and press the Save button to update the settings. Our Twitter application is now ready.

Open your Rails project, and create a .env file in the project root (if you don't have one yet). Add the Twitter keys you obtain earlier.

# .env

TWITTER_API_KEY=KEY FORM TWITTER
TWITTER_API_SECRET=KEY FORM TWITTER
TWITTER_API_BEARER_TOKEN=KEY FORM TWITTER
TWITTER_API_CALLBACK=http://localhost:4200/twitter_login_oauth_callback

The TWITTER_API_CALLBACK is pointing to your client app, and need to match with the one we submitted to Twitter earlier.

Add Twitter OAuth helper file

Create a twitter.rb file inside app/services/Oauth/ and copy and paste the code below to the file.

# app/services/Oauth/twitter.rb
require 'net/http'

module Oauth
  class Twitter
    def initialize(params)
      @callback = params[:callback]
      @app_key = params[:app_key]
      @app_secret = params[:app_secret]
    end


    def get_redirect
      # our front end will go to the callback url
      # and user will need to login from there

      # e.g.
      # https://api.twitter.com/oauth/authenticate?oauth_token=Bviz-wAAAAAAiEDZAAABdOLQn-s
      tokens = get_request_token
      oauth_token = tokens["oauth_token"]
      oauth_token_secret = tokens["oauth_token_secret"]

      callback_url = "https://api.twitter.com/oauth/authenticate?oauth_token=#{oauth_token}"

      return {
        "oauth_token": oauth_token,
        "url": callback_url,
        "oauth_token_secret": oauth_token_secret
      }

    end 

    def obtain_access_token(oauth_token, oauth_token_secret, oauth_verifier)
      tokens = get_access_token(oauth_token, oauth_token_secret, oauth_verifier)
    end

    private

    def get_access_token(oauth_token, oauth_token_secret, oauth_verifier)
      method = 'POST'
      uri = "https://api.twitter.com/oauth/access_token"
      url = URI(uri)
      oauth_timestamp = Time.now.getutc.to_i.to_s
      oauth_nonce = generate_nonce

      oauth_params = {
        'oauth_consumer_key' => @app_key, # Your consumer key
        'oauth_nonce' => oauth_nonce, # A random string, see below for function
        'oauth_signature_method' => 'HMAC-SHA1', # How you'll be signing (see later)
        'oauth_timestamp' => oauth_timestamp, # Timestamp
        'oauth_version' => '1.0', # oAuth version
        'oauth_verifier' => oauth_verifier,
        'oauth_token' => oauth_token
      }

      oauth_params['oauth_callback'] = url_encode(@callback+"\n")
      oauth_callback = oauth_params['oauth_callback']

      base_string = signature_base_string(method, uri, oauth_params)      
      oauth_signature = url_encode(sign(@app_secret + '&', base_string))

      authorization = "OAuth oauth_callback=\"#{oauth_callback}\", oauth_consumer_key=\"#{@app_key}\", oauth_nonce=\"#{oauth_nonce}\", oauth_signature=\"#{oauth_signature}\", oauth_signature_method=\"HMAC-SHA1\", oauth_timestamp=\"#{oauth_timestamp}\", oauth_token=\"#{oauth_token}\", oauth_verifier=\"#{oauth_verifier}\", oauth_version=\"1.0\""
      # authorization = 'OAuth oauth_callback="http%3A%2F%2Flocalhost%3A9000%2Ftwitter_connection%0A", oauth_consumer_key="QJImAUogu5MUalOP2Tv5jRt3X", oauth_nonce="a9900fe68e2573b27a37f10fbad6a755", oauth_signature="Y6y8dg4ENFXorvDPu7kyjrdbVYI%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1601796648", oauth_token="NPASDwAAAAAAiEDZAAABdOzo3sU", oauth_verifier="KiPMEx5rkceLjH1sCV3LfIVsxko0sBrc%0A", oauth_version="1.0"'

      http = Net::HTTP.new(url.host, url.port)
      http.use_ssl = true
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      request = Net::HTTP::Post.new(url)
      request["authorization"] = authorization

      response = http.request(request)

      parse_response_body(response)
    end

    def get_request_token
      # https://wiki.openstreetmap.org/wiki/OAuth_ruby_examples
      # http://www.drcoen.com/2011/12/oauth-1-0-in-ruby-without-a-gem/
      # http://www.drcoen.com/2011/12/oauth-with-the-twitter-api-in-ruby-on-rails-without-a-gem/

      method = 'POST'
      uri = "https://api.twitter.com/oauth/request_token"
      url = URI(uri)
      oauth_timestamp = Time.now.getutc.to_i.to_s
      oauth_nonce = generate_nonce

      oauth_params = {
        'oauth_consumer_key' => @app_key, # Your consumer key
        'oauth_nonce' => oauth_nonce, # A random string, see below for function
        'oauth_signature_method' => 'HMAC-SHA1', # How you'll be signing (see later)
        'oauth_timestamp' => oauth_timestamp, # Timestamp
        'oauth_version' => '1.0' # oAuth version
      }

      oauth_params['oauth_callback'] = url_encode(@callback+"\n")

      base_string = signature_base_string(method, uri, oauth_params)
      oauth_signature = url_encode(sign(@app_secret + '&', base_string))

      oauth_callback = oauth_params['oauth_callback']

      authorization = "OAuth oauth_callback=\"#{oauth_callback}\", oauth_consumer_key=\"#{@app_key}\", oauth_nonce=\"#{oauth_nonce}\", oauth_signature=\"#{oauth_signature}\", oauth_signature_method=\"HMAC-SHA1\", oauth_timestamp=\"#{oauth_timestamp}\", oauth_version=\"1.0\""
      puts authorization

      http = Net::HTTP.new(url.host, url.port)
      http.use_ssl = true
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      request = Net::HTTP::Post.new(url)
      request["authorization"] = authorization
      response = http.request(request)

      parse_response_body(response)

    end

    def url_encode(string)
      CGI::escape(string)
    end

    def signature_base_string(method, uri, params)
      encoded_params = params.sort.collect{ |k, v| url_encode("#{k}=#{v}") }.join('%26')
      method + '&' + url_encode(uri) + '&' + encoded_params
    end

    def sign(key, base_string)
      # digest = OpenSSL::Digest::Digest.new('sha1')
      digest = OpenSSL::Digest::SHA1.new
      hmac = OpenSSL::HMAC.digest(digest, key, base_string)
      Base64.encode64(hmac).chomp.gsub(/\n/, '')
    end

    def generate_nonce(size=7)
      Base64.encode64(OpenSSL::Random.random_bytes(size)).gsub(/\W/, '')
    end  

    def parse_response_body(response)
      ret = {}
      body = response.read_body
      body.split('&').each do |pair|
        key_and_val = pair.split('=')
        ret[key_and_val[0]] = key_and_val[1]
      end

      ret
    end
  end
end

OAuth step-by-step

Step 1. User clicks the "Sign in with Twitter" button in the client app

Step 2. Redirect to the Twitter consent screen to authenticate our app.

In my case, clicking the button calls the oauth_sessions_controller's get_login_link method, which then redirect the page.

Create a new file oauth_sessions_controller.rb, and put it under your controllers/api/v1 directory

# controllers/api/v1/oauth_sessions_controller.rb

module Api::V1
  class OauthSessionsController < ApplicationController

    # GET /v1/twitter_sign_in_link
    def get_login_link
      redirect_to OauthSession.get_sign_in_redirect_link
    end
  end
end

Add a line in your config/routes.rb file to setup the /v1/twitter_sign_in_link endpoint.

# config/routes.rb

scope module: 'api' do
  namespace :v1 do
    ...

     get 'twitter_sign_in_link' => 'oauth_sessions#get_login_link'

    ...
  end
end

You might ask. What is OauthSession.get_sign_in_redirect_link? This is going to return us a redirect URL to the Twitter consent screen.

OauthSession

To implement the get_sign_in_redirect_link, we will first generate the OauthSession model.

Execute this from your command line.

rails g model OauthSession

This will do many things, including:

  • Create a OauthSession model, which stores information such as oauth_token, and oauth_token_secret, and a corresponding migration file

Add the following code to the newly generated migration file, which looks something like YYYYMMDDTTTT_create_oauth_sessions.rb

class CreateOauthSessions < ActiveRecord::Migration[6.1]
  def change
    create_table :oauth_sessions do |t|
      t.string :provider
      t.string :oauth_token
      t.string :oauth_token_secret

      t.timestamps
    end
  end
end

Migrate database by running rails db:migrate.

Implement the get_sign_in_redirect_link class method to OauthSession.

# models/oauth_session.rb

class OauthSession < ApplicationRecord
  def self.get_sign_in_redirect_link
    params = {callback: ENV['TWITTER_API_CALLBACK'], app_key: ENV['TWITTER_API_KEY'], app_secret: ENV['TWITTER_API_SECRET']}
    twitter_service = Oauth::Twitter.new(params)
    result = twitter_service.get_redirect // {oauth_token, oauth_token_secret, url}


    oauth_token = result[:oauth_token]    
    oauth_token_secret = result[:oauth_token_secret]

    # Save the token and secret to the table, so that we can use it later
    twitter_auth_session = OauthSession.where("oauth_token = ? and provider = ?", oauth_token, "Twitter").first
    unless twitter_auth_session
      twitter_auth_session = OauthSession.new()
    end

    twitter_auth_session.oauth_token = oauth_token
    twitter_auth_session.oauth_token_secret = oauth_token_secret
    twitter_auth_session.provider = "Twitter"
    twitter_auth_session.save

    return result[:url]
  end
end

A quick explanation of the function above:

  1. Make an API to request to Twitter to get oauth_token, oauth_token_secret and url to consent screen.
  2. Save the oauth_token, and oauth_token_secret to the table.

Step 3. The user clicks "Authenticate app" on the consent screen

Step 4. The page redirects to your callback_uri with oauth_token and oauth_verifier.

Example URL to redirect to your callback_uri:

http://localhost:4200/twitter_login_oauth_callback?oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0&oauth_verifier=uw7NjWHT6OJ1MpJOXsHfNxoAhPKpgI8BlYDhxEjIBY

Our client app will need to make an HTTP POST request to our Rails API endpoint (/v1/twitter_sign_in) to pass oauth_token and oauth_verifier back to our backend server.

Add a line in your config/routes.rb file to setup the /v1/twitter_sign_in endpoint.

# config/routes.rb

scope module: 'api' do
  namespace :v1 do
    ...

     post 'twitter_sign_in' => 'twitter_auths#sign_in'

    ...
  end
end

Create a twitter_oauths_controller.rb and save to /controllers/api/v1/

# twitter_oauths_controller.rb

module Api::V1
  class TwitterOauthsController < ApplicationController

    # POST /v1/twitter_sign_in
    def sign_in
      # converting the request token to access token
      oauth_token = params[:oauth_token]
      oauth_verifier = params[:oauth_verifier]

      if oauth_token && oauth_verifier
        user = TwitterAuth.sign_in(oauth_token, oauth_verifier)
        if user
          header = user.create_new_auth_token
          response.set_header('access-token', header["access-token"]) 
          response.set_header('token-type', header["token-type"]) 
          response.set_header('client', header["client"]) 
          response.set_header('expiry', header["expiry"]) 
          response.set_header('uid', header["uid"]) 

          render json: {data: user}, status: :ok
        else
          res = {
            "success": false,
            "errors": [
              "Invalid login credentials. Please try again."
            ]
          }
          render json: res, status: :unauthorized         
        end
      else
        res = {
          "success": false,
          "errors": [
            "Failed to sign in with Twitter"
          ]
        }         
        render json: res, status: :unauthorized
      end

    end

  end
end

A quick explanation of the code above:

  1. If oauth_token and oauth_verifier are available, then we will try to use them to sign in to our application.
  2. If the user is available, then use create_new_auth_token method from devise_token_auth to generate the header meta

Step 5. Exchange the oauth token for an user

To implement the TwitterAuth.sign_in(oauth_token, oauth_verifier), we will first generate the TwitterAuth model.

Execute this from your command line.

rails g model TwitterAuth

Add the following code to the newly generated migration file, which looks something like YYYYMMDDTTTT_create_twitter_auths.rb

class CreateTwitterAuths < ActiveRecord::Migration[6.1]
  def change
    create_table :twitter_auths do |t|
      t.string :twitter_user_id
      t.string :screen_name

      t.references :user, foreign_key: true
      t.timestamps
    end
  end
end

Add the user has one twitter_auth relationship.

  1. In the user.rb, add has_one :twitter_auth
  2. In the twitter_auth.rb, add belongs_to :user

Migrate database by running rails db:migrate.

Implement the sign_in class method to TwitterAuth.

# /models/twitter_auth.rb

class TwitterAuth < ApplicationRecord
  belongs_to :user


  def self.sign_in(oauth_token, oauth_verifier)
    params = {callback: ENV['TWITTER_API_CALLBACK'], app_key: ENV['TWITTER_API_KEY'], app_secret: ENV['TWITTER_API_SECRET']}

    # 1. look up the oauth_token_secret by oauth_token
    twitter_auth_session = OauthSession.where("oauth_token = ? and provider = ?", oauth_token, "Twitter").first
    if twitter_auth_session
      twitter_service = Oauth::Twitter.new(params)
      oauth_token_secret = twitter_auth_session.oauth_token_secret
      twitter_resp = twitter_service.obtain_access_token(oauth_token, oauth_token_secret, oauth_verifier)

      twitter_user_id = twitter_resp["user_id"]
      screen_name = twitter_resp["screen_name"]

      # Here is the fun part
      # 1. we look up if the twitter_user_id exist
      # if yes, then it's an existing user
      # if no, then it's a new user

      # for new user we will create a new account
      # for existing user, we will look up the user info, and return the header for auth

      twitter_auth = TwitterAuth.find_by_twitter_user_id(twitter_user_id)
      if twitter_auth
        # return the user basic on the twitter id
        user = twitter_auth.user        
      else
        # insert a new twitter_auth and also create a new user account        
        str = (0...8).map { (65 + rand(26)).chr }.join
        password = Digest::SHA256.base64digest "#{twitter_user_id}#{screen_name}#{str}".first(8) # generate a password

        user = User.create(email: "#{screen_name}@yourwebsite.com", password: password)

        twitter_auth = TwitterAuth.new()
        twitter_auth.user_id = user.id
        twitter_auth.twitter_user_id = twitter_user_id
        twitter_auth.screen_name = screen_name
        twitter_auth.save        
      end

      twitter_auth_session.delete # remove the auth session 

      return user
    else
      return nil
    end
  end
end

That's all you need to setup a Sign in with Twitter for your Rails API.

In this post, we handled everything manually without replying on gems like omniauth-twitter. This is more a "hack" than a solution, it might be easier and better to use the omniauth-twitter gem. I hope you have enjoyed this tutorial, There is much more to be done, but this should get you started. Thanks for reading!

34