Our problem
We have an API REST developed in RoR and our client has a Single Sign On service which we need to login through SAML 2.0 standard to use our application.
Solution
We’ll see how integrating these 3 gems help us achieve our goal. If you want to know what these 3 gems do, just go to the Github page of each one.
- devise_saml_authenticatble, version 1.5.0 (From now, DSA)
- devise_token_auth, version 1.1.0 (From now, DTA)
- devise, version 4.6.2
Some important concepts
There are a large number of articles about SAML standard, but in my opinion, i didn’t found one that explains correctly these concepts.
- Identity Provider or IdP: The service in which we want to login, generally with a user and a password. This is usually provided by our client.
- Service Provider or SP: Is our API. In my case, my RoR API.
Installations
Previously, we have to have the “create_users” migration and User model, after that, we need to install the 3 gems and run Devise’s generator and then, DTA’s generator setting up the class and the mount path.
Model
We add these two lines in model.
class User < ApplicationRecord
devise :saml_authenticatable
include DeviseTokenAuth::Concerns::User
At the bottom of the file, add this method and leave it empty.
# We don't have passwords
def remove_tokens_after_password_reset; end
We do this because we don’t need passwords in our database, this is work for the IdP.
Routes
We have to rewrite the routes after run the DTA’s generator.
Rails.application.routes.draw do
mount_devise_token_auth_for 'User',
at: 'api/v1/auth',
skip: %i[omniauth_callbacks],
controllers: { sessions: 'api/v1/sessions' }
devise_scope :user do
get '/users/saml/sign_in', to: 'saml/auth#new'
get '/users/saml/metadata', to: 'devise/saml_sessions#metadata'
post '/users/saml/auth', to: 'dta_saml/sessions#create'
delete '/users/saml/dta_logout', to: 'dta_saml/sessions#destroy'
end
Controllers
For the controllers, we create two new ones:
- saml/auth_controller.rb
- dta_saml/sessions_controller.rb
auth_controller.rb
This controller will create the URL and redirect us, with a request, to the IdP to login, also the controller will test out if you are logged in or not. We will rewrite the #new method of DSA to edit the authentication flow.
module Saml
class AuthController < Devise::SamlSessionsController
def new
idp_entity_id = get_idp_entity_id(params)
request = OneLogin::RubySaml::Authrequest.new
auth_params = { RelayState: relay_state } if relay_state
action = request.create(saml_config(idp_entity_id), auth_params || {})
redirect_to action
end
After that, we rewrite the “ require_no_authentication” method of Devise to eliminate all we don’t need and we add a forced logout. This is related to Warden and i won’t explain it here. Keep in mind that the 'action' variable is the URL of the IdP's page
private
def require_no_authentication
assert_is_devise_resource!
return unless is_navigational_format?
no_input = devise_mapping.no_input_strategies
authenticated = validate_credentials(no_input)
logged(authenticated)
end
def validate_credentials(no_input)
if no_input.present?
args = no_input.dup.push scope: resource_name
warden.authenticate?(*args)
else
warden.authenticated?(resource_name)
end
end
def logged(authenticated)
warden.logout
resource = warden.user(resource_name)
redirect_to after_sign_in_path_for(resource) if authenticated && resource
end
sessions_controller.rb
Create
In this controller, we combine all we need of DSA and DTA. When we get the authentication response of the IdP, it will be decoded in the first line of the #create method and it will continue with the common process of DTA returning, in @client_id, 2 variables (client and token) and in @resource, the User object with it’s email. DTA uses these 2 variables and the email to authenticate us.
module DtaSaml
class SessionsController < DeviseTokenAuth::SessionsController
def create
# retrieves the user to authenticate based on SamlResponse
@resource = warden.authenticate!(auth_options)
@client_id, @token = @resource.create_token
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
yield @resource if block_given?
# Here you can return a JWT token with the 3 variables
end
def destroy
warden.logout
super
end
protected
def auth_options
{ scope: :user, recall: 'devise/sessions#new' }
end
At this point, I suggest use JWT to create a token with those 3 variables and retrieve it to the Frontend to continue the authentication flow of DTA, but we won’t see it in this article.
Destroy
The #destroy method, destroys the DTA's session invalidating the auth tokens.
Configuration
/config/initializers/devise.rb and /config/attribute-map.yml
Configure these files as DSA’s page shows. The necessary data to configure the /config/initializers/devise.rb file should be provided by the IdP.
Migration
Modify the DTA’s migration and migrate.
def change
change_table(:users) do |t|
t.string :provider, :null => false, :default => "email"
t.string :uid, :null => false, :default => ""
## Tokens
t.json :tokens
end
add_index :users, [:uid, :provider], unique: true
end
Notes
If you have some problem with URL’s creation, you can create them manually.
Conclusion
We're done!
Now, we can use at the same time, a SSO by SAML 2.0 with an API REST developed Ruby on Rails,
I hope you find it useful, thank you for reading,
Alan
Top comments (0)