SAML with Ruby
Terminology
- Identity Provider (IdP): The SAML identity provider. In our case, this will be Okta.
- Service Provider (SP): The application you are creating.
- Metadata URL: A URL that specifies the location of a metadata.xml file that defines how your application is configured for use with a particular IdP. Typically, your application will check this URL each time it is started and download the contents of the file.
Create an app in Okta
Create a developer account
Add an application
The SSO endpoint for this application is:
- development:
http://localhost:3000/saml/acs
- production:
https://DOMAIN/saml/acs
In Okta, this URL would be specified for both Single Sign on URL
and
Audience URI
.
Once the identity provider is configured, copy the IdP metadata URL.
Basic service provider
Set environment variable
Then, set the environment variable IPD_METADATA_URL before the application is run. For example:
In config/development.sh
:
export IDP_METADATA_URL="https://dev-770989.oktapreview.com/app/exk9dbq3zdHbEBp2e0h7/sso/saml/metadata"
Then, to run your application:
source config/development.sh
rails server
In production, you would set the environment viariable via a deployment pipeline.
Gemfile
In Gemfile
gem 'ruby-saml', '~> 1.4'
Routes
in config/routes.rb
:
Rails.application.routes.draw do
get 'saml/login', to: 'saml#login', as: 'login'
post 'saml/acs', to: 'saml#acs'
end
SAML Controller
in app/controllers/saml_controller.rb
:
#
# A SAML service provider controller
#
class SamlController < ApplicationController
skip_before_action :verify_authenticity_token, :only => [:acs]
skip_before_action :require_authentication
skip_before_action :require_authorization
#
# GET /saml/login
#
# SP initiated login action. Redirects to IdP.
#
def login
request = OneLogin::RubySaml::Authrequest.new
redirect_to(request.create(saml_settings))
end
#
# POST /saml/acs
#
# Assertion Consumer Service URL. The endpoint that the IdP posts to.
#
def acs
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], :settings => saml_settings)
reset_session
session[:user_id] = response.nameid
redirect_to start_url
end
#
# POST /saml/logout
#
def logout
reset_session
redirect_to root_url
end
private
def saml_settings
@settings ||= begin
if ENV['IDP_METADATA_URL'] && ENV['IDP_METADATA_URL'].present?
OneLogin::RubySaml::IdpMetadataParser.new.parse_remote(ENV['IDP_METADATA_URL'])
else
raise StandardError, "The environment variable IDP_METADATA_URL is not set."
end
end
end
end
This controller assumes you have routes for start_url
and root_url
. This also assumes you have a require_authentication
and require_authorization
before action callbacks defined. Change these as appropriate.
Login link
Put this in a view somewhere:
<%= link_to "Log in", login_path, class: 'login-btn' %>
Add support for groups
Lets suppose you want to give access to your application to these three groups that are defined in Okta:
-
app_developers
, with Okta group ID0000aaaa
-
app_admins
with Okta group ID1111bbbb
-
app_readers
with Okta group ID2222cccc
The actual group IDs in Okta look more like 00g1erqthk0Why5qd0h8
, but for the purpose of this tutorial we have simplified the IDs.
Add groups attribute to Okta
The first step is to modify the application configuration in Okta to add a SAML property:
- Name:
groups
- Value:
getFilteredGroups({"0000aaaa","1111bbbb", "2222cccc"}, "{group.id,group.name}", 10)
Configure environment
Lets suppose you want to add two simple roles to your application:
- Administration role: These users can edit everything.
- Read only role: These users can read but not edit.
For this, create the following environment variables:
ADMIN_GROUPS – A list of SAML group IDs for users who should have full admin access to the web application.
READONLY_GROUPS – A list of SAML group IDs for users who should have READ ONLY access to the web application.
For example, in config/development.sh
:
export ADMIN_GROUPS="0000aaaa 1111bbbb"
export READONLY_GROUPS="2222cccc"
In this examples, app_developers
and app_admins
both gain the admin role, and app_readers
gain the readonly role.
Modify SAML controller
First, add a call to resolve_group_role
. This will store the role and the group name in the session when the user authenticates.
#
# A SAML service provider controller
#
class SamlController < ApplicationController
def acs
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], :settings => saml_settings)
reset_session
session[:user_id] = response.nameid
+ session[:role], session[:group] = resolve_group_role(response)
redirect_to start_url
end
Now lets define resolve_group_role
:
class SamlError < StandardError; end
rescue_from SamlError, :with => :error
private
def error(exception)
render status: 500, text: exception.to_s
end
def resolve_group_role(response)
group_str = response.attributes["groups"]
if group_str.nil?
raise SamlError, "The SAML response must include the `groups` attribute. See README.md"
end
groups = parse_user_groups(group_str)
if group = find_group(groups, ENV['ADMIN_GROUPS'])
return [:admin, group]
elsif group = find_group(groups, ENV['READONLY_GROUPS'])
return [:reader, group]
else
raise SamlError, "You do not belong to any groups with access to this application. Your groups are #{groups.inspect}."
end
end
#
# parses the list of groups that a user is a member of, as reported by saml assertion.
#
# configured in okta:
#
# groups => getFilteredGroups({"00gcyt4a07m0hu0pe0h7","00gcyt4j78O335Ntv0h7"}, "{group.id, group.name}", 10)
#
# example response.attributes["groups"]:
#
# "00gcyt4a07m0hu0pe0h7,inventory_read,00gcyt4j78O335Ntv0h7,inventory_write"
#
# NOTE: this will fail horribly if there is a comma in the group name.
#
def parse_user_groups(group_str)
groups = {}
ids_and_names = group_str.split(',')
while ids_and_names.any?
id = ids_and_names.shift.strip
name = ids_and_names.shift.strip
groups[id] = name
end
return groups
rescue
raise SamlError, "ERROR: failed to parse `group` attrbute string from SAML. The string was: #{group_str.inspect}"
end
#
# returns a group, in the form {id: group.id, name: group.name}, of the first group
# we can find that is in both user_groups and target_groups
#
# user_groups: a hash of group names, indexed by group id
# target_groups: a string of group ids, separated by commas or whitespace
#
def find_group(user_groups, target_groups)
if target_groups
target_groups.split(/[\s,]+/).each do |group_id|
if user_groups[group_id]
return {id: group_id, name: user_groups[group_id]}
end
end
end
return nil
end
end
How would you use this? Here is a very barebones authorization code you might use:
class ApplicationController < ActionController::Base
NotAuthorized = Class.new(StandardError)
before_action :require_authentication
before_action :require_authorization
rescue_from ApplicationController::NotAuthorized, :with => :render_unauthorized
protected
def render_unauthorized
render :file => Rails.root.join('public', '422.html'), :status => 403
end
def current_user
if Rails.env != "production" && ENV["AUTHENTICATION_BYPASS"]
session[:role] = "admin"
ENV["AUTHENTICATION_BYPASS"]
else
session[:user_id]
end
end
helper_method :current_user
def require_authentication
unless current_user
redirect_to login_url
end
end
def require_authorization
if is_admin?
return true
elsif is_reader? && read_only_request?
return true
else
raise NotAuthorized
end
end
def is_admin?
session[:role] == "admin"
end
helper_method :is_admin?
def is_reader?
session[:role] == "reader"
end
helper_method :is_reader?
end