Tailor Doorkeeper with Refresh Tokens, Views, and Strategies
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!