Tailor Doorkeeper with Refresh Tokens, Views, and Strategies

Ilya Bodrov-Krukowski
Share

In the previous part of this series we met Doorkeeper, a Rails engine to build custom OAuth 2 providers. I showed how to integrate this solution into your app and how to use it to secure API requests.

Currently, users are able to register their OAuth 2 apps, receive access tokens, perform API requests, and work with scopes. However, there are a few things left to take care of:

  • The list of OAuth 2 applications can be accessed by anyone
  • Users cannot obtain refresh tokens and use them to fetch new access tokens
  • It would be nice to craft a custom OmniAuth strategy that can be later packed as a gem
  • We need a way to tweak views and routes

Therefore, in this article we will attend to these issues.

Source code can be found on GitHub.

If you didn’t follow the instructions in the previous part, you may clone this branch.

Securing OAuth 2 Applications

Just to remind you, at this point we have two apps: a so-called “server” (the actual authentication provider) and a “client” (an app for testing purposes). Currently, even if a user is not authenticated, they can still visit localhost:3000/oauth/applications and browse the list of registered OAuth 2 applications. Obviously, that’s not good, so we need a way to restrict access to that page.

Luckily, Doorkeeper provides a handy way to solve this problem. Tweak Doorkeeper’s initializer file:

config/initializers/doorkeeper.rb

[...]
admin_authenticator do
  User.find_by_id(session[:user_id]) || redirect_to(new_session_url)
end
[...]

This is very similar to what we did in the previous part, however now we are setting admin_authenticator rather than resource_owner_authenticator. For the real app you’d probably want to use a more complex solution (like requiring the user to have an “admin” role, etc.), but this is fine for demonstration purposes.

Reload the server and navigate to localhost:3000/oauth/applications. If you are not authenticated, you’ll be redirected to the log in page. This means that everything is working just as planned.

Introducing Refresh Tokens

By default access tokens expire in 2 hours, after which users have to repeat the authentication process again. If you need to tweak this behavior, uncomment and modify the access_token_expires_in 2.hours line inside the Doorkeeper’s initializer file. By the way, here you may also tweak the authorization_code_expires_in 10.minutes setting. This code is used during the authentication phase to receive the access token and its lifespan should be short.

However, for user convenience we may want to introduce refresh tokens. By default, Doorkeeper does not issue them, but that can be changed easily:

config/initializers/doorkeeper.rb

[...]
use_refresh_token
[...]

Don’t forget to reboot your server after changing this file.

Now open your client app and modify SessionsController to store refresh tokens and access token expiration time:

sessions_controller.rb

class SessionsController < ApplicationController
  def create
    req_params = "client_id=#{ENV['oauth_token']}&client_secret=#{ENV['oauth_secret']}&code=#{params[:code]}&grant_type=authorization_code&redirect_uri=#{ENV['oauth_redirect_uri']}"
    response = JSON.parse RestClient.post("#{ENV['server_base_url']}/oauth/token", req_params)
    session[:access_token] = response['access_token']
    session[:refresh_token] = response['refresh_token']
    session[:expires_at] = response['expires_in'].seconds.from_now
    redirect_to root_path
  end
end

response['expires_in'] contains number of seconds saying how soon the token will expires but, for our convenience, I am turning this to a DateTime object.

Now introduce two new routes:

config/routes.rb

[...]
get '/user', to: 'users#show', as: :user
get '/user/update', to: 'users#update', as: :update_user
[...]

Currently our main page has the following contents:

views/pages/index.html.erb

<h1>Welcome!</h1>

<% if session[:access_token] %>
  <%= link_to 'Get User', "http://localhost:3000/api/user?access_token=#{session[:access_token]}" %>
  <%= link_to 'Update User', "http://localhost:3000/api/user/update?access_token=#{session[:access_token]}" %>
<% else %>
  <%= link_to 'Authorize via Keepa', new_oauth_token_path %>
<% end %>

I want to extract these “Get User” and “Update User” URLs into the controller actions and that’s why we created new routes:

views/pages/index.html.erb

[...]
<% if session[:access_token] %>
  <%= link_to 'Get User', user_path %>
  <%= link_to 'Update User', update_user_path %>
<% else %>
  <%= link_to 'Authorize via Keepa', '/auth/keepa' %>
<% end %>

Here is the new controller:

users_controller.rb

class UsersController < ApplicationController
  def show
    redirect_to "http://localhost:3000/api/user?access_token=#{session[:access_token]}"
  end

  def update
    redirect_to "http://localhost:3000/api/user/update?access_token=#{session[:access_token]}"
  end
end

What we need to do now is to check whether an access token has expired before performing an API request. The easiest way would be to employ a before_action:

users_controller.rb

[...]
before_action :check_access_token
[...]
private

def check_access_token
  redirect_to root_path unless session[:access_token]
  if session[:expires_at] <= Time.current
    req_params = "client_id=#{ENV['oauth_token']}&client_secret=#{ENV['oauth_secret']}&refresh_token=#{session[:refresh_token]}&grant_type=refresh_token&redirect_uri=#{ENV['oauth_redirect_uri']}"
    response = JSON.parse RestClient.post("#{ENV['server_base_url']}/oauth/token", req_params)
    set_oauth_info_from response
  end
end

If no access token is set, we simply redirect the user back. If the token has expired, we generate a new string with request params client_id, client_secret, refresh_token, redirect_uri, and grant_type (that has to be set to refresh_token). You may ask, what about the scope? By default, a new access token will be granted to perform the same actions that were initially approved by the user. You may narrow the scope, but you cannot add scopes that were not provided previously.

Next we take the response and pass it to the set_oauth_info_from method that will be introduced now:

application_controller.rb

[...]
private

def set_oauth_info_from(response)
  session[:access_token] = response['access_token']
  session[:refresh_token] = response['refresh_token']
  session[:expires_at] = response['expires_in'].seconds.from_now
end

Use this method inside the SessionsController to avoid code duplication:

sessions_controller.rb

def create
  req_params = "client_id=#{ENV['oauth_token']}&client_secret=#{ENV['oauth_secret']}&code=#{params[:code]}&grant_type=authorization_code&redirect_uri=#{ENV['oauth_redirect_uri']}"
  response = JSON.parse RestClient.post("#{ENV['server_base_url']}/oauth/token", req_params)
  set_oauth_info_from response
  redirect_to root_path
end

That’s it! Now our users are able to receive refresh tokens without any problem!

Building a Custom OmniAuth Provider

If your app becomes popular enough one day, you may want to craft a custom OmniAuth strategy and distribute it as a gem. It appears that this can be done pretty easily. Of course, we are not going to create a separate gem, rather, let’s store our new strategy inside the lib folder of the client’s app.

lib/omniauth/strategies/keepa.rb

require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class Keepa < OmniAuth::Strategies::OAuth2
      option :name, :keepa

      option :client_options, {
          :site => "http://localhost:3000",
          :authorize_url => "/oauth/authorize"
      }

      uid { raw_info["id"] }

      info do
        {
            :email => raw_info["email"]
        }
      end

      def raw_info
        @raw_info ||= access_token.get('/api/user').parsed
      end
    end
  end
end

I’ve named my strategy Keepa, so the class name and the option :name should be set accordingly.

The raw_info method is used to obtain information about a user that authenticates via our strategy. /api/user is the route that we’ve introduced in the previous article. Let me remind you how the corresponding action looks like:

api/users_controller.rb

[...]
def show
  render json: current_resource_owner.as_json
end

private

def current_resource_owner
  User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end
[...]

We basically fetch the user who has initiated the authentication phase and render his information in JSON format.

uid { raw_info["id"] } explains what field to use as a unique identifier. We are using the identifier from the table, but you may introduce a special field instead.

info do
  {
      :email => raw_info["email"]
  }
end

This indicates what fields will be available inside the callback action after a successful authentication. You may add other fields as necessary.

This code depends on omniauth-oauth2 abstract strategy so hook it up:

Gemfile

[...]
gem 'omniauth-oauth2', '1.3.1'
[...]

and run

$ bundle install

I am using version 1.3.1 here because of this issue that affects Doorkeeper.

Now create a new initializer file for the client app:

config/initializers/omniauth.rb

require File.expand_path('lib/omniauth/strategies/keepa', Rails.root)

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :keepa, ENV['oauth_token'], ENV['oauth_secret'], scope: 'public write'
end

If you have ever used OmniAuth in your projects, this should be very familiar. The only thing to note is that we need to require the strategy file explicitly as it is not being loaded automatically.

public and write are the scopes that were introduced in the previous article. A space should be used as a delimiter.

OmniAuth instructs us to add a special callback route, so replace the old one:

config/routes.rb

[...]
get '/oauth/callback', to: 'sessions#create'
[...]

with

config/routes.rb

[...]
get '/auth/:provider/callback', to: 'sessions#create'
[...]

Modify the config file:

config/local_env.yml

[...]
oauth_redirect_uri: 'http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fkeepa%2Fcallback'

Also don’t forget to open your server app, navigate to localhost:3000/oauth/applications, and edit the redirect URI for your application as well.

Alter the main page of the client app:

views/pages/index.html.erb

[...]
<% if session[:access_token] %>
  <%= link_to 'Get User', user_path %>
  <%= link_to 'Update User', update_user_path %>
<% else %>
  <%= link_to 'Authorize via Keepa', '/auth/keepa' %>
<% end %>

The next step is to alter the callback action:

sessions_controller.rb

[...]
def create
  set_oauth_info_from request.env['omniauth.auth']['credentials']
  redirect_to root_path
end
[...]

request.env['omniauth.auth'] should be familiar to you. It contains the authentication hash in the following format:

{"provider"=>:keepa,
 "uid"=>1,
 "info"=>{"email"=>"test@example.org"},
 "credentials"=>
  {"token"=>"8d8...",
   "refresh_token"=>"321...",
   "expires_at"=>1453301961,
   "expires"=>true},
 "extra"=>{}}

Note that the field is called token (not access_token) and instead of expires_in we have expires_at.

Therefore the set_oauth_info_from method should be changed a bit:

application_controller.rb

[...]
def set_oauth_info_from(response)
  session[:access_token] = response['access_token'] || response['token']
  session[:refresh_token] = response['refresh_token']
  if response['expires_in']
    session[:expires_at] = response['expires_in'].seconds.from_now
  else
    session[:expires_at] = Time.at response['expires_at']
  end
end
[...]

The new_oauth_token_path helper method can be removed completely and you are ready to authenticate via your new shiny strategy. Great!

Further Customization

You are probably wondering whether it is possible to modify Doorkeeper’s views and routes. And yes – this is totally possible.

To alter the views, run this simple command:

$ rails generate doorkeeper:views

It will generate all the views and layouts used by Doorkeeper.

You are free to employ your own layouts by tweaking these settings inside the application.rb file:

config.to_prepare do
  Doorkeeper::ApplicationsController.layout "my_layout"

  Doorkeeper::AuthorizationsController.layout "my_layout"

  Doorkeeper::AuthorizedApplicationsController.layout "my_layout"
end

Routes and helper methods from your app can be utilized inside the Doorkeeper’s views, but they had to be prefixed with the main_app (for example, main_app.login_path) because this solution is an isolated engine.

Tweaking default routes is pretty simple as well. The use_doorkeeper method accepts a block with all the necessary configurations. For example, you can use own controller to manage OAuth 2 applications:

use_doorkeeper do
  controllers :applications => 'custom_applications'
end

controllers accepts :authorizations, :tokens, :applications, and :authorized_applications values.

You may even skip some controllers by saying:

use_doorkeeper do
  skip_controllers :applications
end

skip_controllers accepts the same values.

Refer to this wiki page for more information.

Conclusion

In this article we finalized our custom OAuth 2 provider by securing it a bit, introducing refresh tokens, and crafting a custom OmniAuth strategy. Now you are ready to further customize and enhance you app as needed! I really encourage you to browse the Doorkeeper wiki as it provides some nice tutorials on how to solve common problems.

I hope that this series of articles was useful and interesting to you. If you want me cover a specific topic don’t hesitate to ask – I am always looking forward to your feedback. Happy coding and stay authenticated!

CSS Master, 3rd Edition