Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ In config/initializers/devise.rb
settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
settings.issuer = "http://localhost:3000"
settings.authn_context = ""
settings.idp_slo_target_url = "http://localhost/simplesaml/www/saml2/idp/SingleLogoutService.php"
settings.idp_sso_target_url = "http://localhost/simplesaml/www/saml2/idp/SSOService.php"
settings.idp_cert = <<-CERT.chomp
-----BEGIN CERTIFICATE-----
Expand Down Expand Up @@ -110,10 +111,13 @@ There are numerous IdPs that support SAML 2.0, there are propietary (like Micros

[SimpleSAMLphp](http://simplesamlphp.org/) was my choice for development since it is a production-ready SAML solution, that is also really easy to install, configure and use.

## Logout

Logout support is included by immediately terminating the local session and then redirecting to the IdP.

## Limitations

1. At the moment there is no support for Single Logout
2. The Authentication Requests (from your app to the IdP) are not signed and encrypted
1. The Authentication Requests (from your app to the IdP) are not signed and encrypted

## Thanks

Expand Down
9 changes: 8 additions & 1 deletion app/controllers/devise/saml_sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ def metadata
meta = OneLogin::RubySaml::Metadata.new
render :xml => meta.generate(@saml_config)
end


protected

# Override devise to send user to IdP logout for SLO
def after_sign_out_path_for(_)
request = OneLogin::RubySaml::Logoutrequest.new
request.create(@saml_config)
end
end

16 changes: 14 additions & 2 deletions spec/controllers/devise/saml_sessions_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
require 'rails_helper'

class Devise::SessionsController < ActionController::Base

# The important parts from devise
def destroy
sign_out
redirect_to after_sign_out_path_for(:user)
end
end

require_relative '../../../app/controllers/devise/saml_sessions_controller'
Expand All @@ -18,7 +22,7 @@ class Devise::SessionsController < ActionController::Base
end

describe '#metadata' do
it "generates metadata" do
it 'generates metadata' do
get :metadata

# Remove ID that can vary across requests
Expand All @@ -27,4 +31,12 @@ class Devise::SessionsController < ActionController::Base
expect(response.body).to match(Regexp.new(metadata_pattern))
end
end

describe '#destroy' do
it 'signs out and redirects to the IdP' do
expect(controller).to receive(:sign_out)
delete :destroy
expect(response).to redirect_to(%r(\Ahttp://localhost:8009/saml/logout\?SAMLRequest=))
end
end
end
32 changes: 32 additions & 0 deletions spec/features/saml_authentication_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'spec_helper'
require 'net/http'
require 'timeout'
require 'uri'
require 'capybara/rspec'
require 'capybara/webkit'
Expand Down Expand Up @@ -32,6 +33,21 @@
expect(page).to have_content("A User")
expect(current_url).to eq("http://localhost:8020/")
end

it "logs a user out of the IdP via the SP" do
sign_in

# prove user is still signed in
visit 'http://localhost:8020/'
expect(page).to have_content("you@example.com")
expect(current_url).to eq("http://localhost:8020/")

click_on "Log out"

# prove user is now signed out
visit 'http://localhost:8020/'
expect(current_url).to match(%r(\Ahttp://localhost:8009/saml/auth\?SAMLRequest=))
end
end

context "when the attributes are used to authenticate" do
Expand Down Expand Up @@ -68,4 +84,20 @@ def create_user(email)
response = Net::HTTP.post_form(URI('http://localhost:8020/users'), email: email)
expect(response.code).to eq('201')
end

def sign_in
visit 'http://localhost:8020/'
expect(current_url).to match(%r(\Ahttp://localhost:8009/saml/auth\?SAMLRequest=))
fill_in "Email", with: "you@example.com"
fill_in "Password", with: "asdf"
click_on "Sign in"
Timeout.timeout(Capybara.default_wait_time) do
loop do
sleep 0.1
break if current_url == "http://localhost:8020/"
end
end
rescue Timeout::Error
expect(current_url).to eq("http://localhost:8020/")
end
end
2 changes: 2 additions & 0 deletions spec/support/idp_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@

route "get '/saml/auth' => 'saml_idp#new'"
route "post '/saml/auth' => 'saml_idp#create'"
route "get '/saml/logout' => 'saml_idp#logout'"

template File.expand_path('../saml_idp_controller.rb.erb', __FILE__), 'app/controllers/saml_idp_controller.rb'
copy_file File.expand_path('../saml_idp-saml_slo_post.html.erb', __FILE__), 'app/views/saml_idp/saml_slo_post.html.erb'
13 changes: 13 additions & 0 deletions spec/support/saml_idp-saml_slo_post.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
</head>
<body onload="document.forms[0].submit();" style="visibility:hidden;">
<%= form_tag(@saml_slo_acs_url) do %>
<%= hidden_field_tag("SAMLResponse", @saml_slo_response) %>
<%= submit_tag "Submit" %>
<% end %>
</body>
</html>
87 changes: 86 additions & 1 deletion spec/support/saml_idp_controller.rb.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
class SamlIdpController < SamlIdp::IdpController
def new
if session[:user_id]
@saml_response = idp_make_saml_response(session[:user_id])
render :template => "saml_idp/idp/saml_post", :layout => false
return
end
super
end

protected

def idp_authenticate(email, password)
session[:user_id] = "you@example.com"
true
end

def idp_make_saml_response(user)
def idp_make_saml_response(_)
attributes = {
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" => "A User",
}
Expand Down Expand Up @@ -51,4 +63,77 @@ class SamlIdpController < SamlIdp::IdpController
def include_subject_in_attributes
<%= @include_subject_in_attributes %>
end

# == SLO functionality, see https://github.com/lawrencepit/ruby-saml-idp/pull/10
skip_before_filter :validate_saml_request, :only => [:logout]
before_filter :validate_saml_slo_request, :only => [:logout]

public

def logout
_person, _logout = idp_slo_authenticate(params[:name_id])
if _person && _logout
@saml_slo_response = idp_make_saml_slo_response(_person)
else
@saml_idp_fail_msg = 'User not found'
logger.error "User with email #{params[:name_id]} not found"
@saml_slo_response = encode_SAML_SLO_Response(params[:name_id])
end
if @saml_slo_acs_url
render :template => "saml_idp/idp/saml_slo_post", :layout => false
else
redirect_to 'http://example.com'
end
end

def idp_slo_authenticate(email)
session.delete :user_id
true
end

def idp_make_saml_slo_response(person)
attributes = {}
if include_subject_in_attributes
attributes["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] = "you@example.com"
end
encode_SAML_SLO_Response("you@example.com", attributes: attributes)
end

private

def validate_saml_slo_request(saml_request = params[:SAMLRequest])
decode_SAML_SLO_Request(saml_request)
end

def decode_SAML_SLO_Request(saml_request)
zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS)
@saml_slo_request = zstream.inflate(Base64.decode64(saml_request))
zstream.finish
zstream.close
@saml_slo_request_id = @saml_slo_request[/ID=['"](.+?)['"]/, 1]
@saml_slo_acs_url = @saml_slo_request[/AssertionConsumerLogoutServiceURL=['"](.+?)['"]/, 1]
end

def encode_SAML_SLO_Response(nameID, opts = {})
now = Time.now.utc
response_id, reference_id = UUID.generate, UUID.generate
audience_uri = opts[:audience_uri] || (@saml_slo_acs_url && @saml_slo_acs_url[/^(.*?\/\/.*?\/)/, 1])
issuer_uri = opts[:issuer_uri] || (defined?(request) && request.url.split("?")[0]) || "http://example.com"

assertion = %[<Assertion xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="_#{reference_id}" IssueInstant="#{now.iso8601}" Version="2.0"><Issuer>#{issuer_uri}</Issuer><Subject><NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">#{nameID}</NameID><SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><SubjectConfirmationData InResponseTo="#{@saml_slo_request_id}" NotOnOrAfter="#{(now+3*60).iso8601}" Recipient="#{@saml_slo_acs_url}"></SubjectConfirmationData></SubjectConfirmation></Subject><Conditions NotBefore="#{(now-5).iso8601}" NotOnOrAfter="#{(now+60*60).iso8601}"><AudienceRestriction><Audience>#{audience_uri}</Audience></AudienceRestriction></Conditions><AttributeStatement><Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"><AttributeValue>#{nameID}</AttributeValue></Attribute></AttributeStatement><AuthnStatement AuthnInstant="#{now.iso8601}" SessionIndex="_#{reference_id}"><AuthnContext><AuthnContextClassRef>urn:federation:authentication:windows</AuthnContextClassRef></AuthnContext></AuthnStatement></Assertion>]

digest_value = Base64.encode64(algorithm.digest(assertion)).gsub(/\n/, '')

signed_info = %[<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-#{algorithm_name}"></ds:SignatureMethod><ds:Reference URI="#_#{reference_id}"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></ds:Transform><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig##{algorithm_name}"></ds:DigestMethod><ds:DigestValue>#{digest_value}</ds:DigestValue></ds:Reference></ds:SignedInfo>]

signature_value = sign(signed_info).gsub(/\n/, '')

signature = %[<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">#{signed_info}<ds:SignatureValue>#{signature_value}</ds:SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>#{self.x509_certificate}</ds:X509Certificate></ds:X509Data></KeyInfo></ds:Signature>]

assertion_and_signature = assertion.sub(/Issuer\>\<Subject/, "Issuer>#{signature}<Subject")

xml = %[<samlp:LogoutResponse ID="_#{response_id}" Version="2.0" IssueInstant="#{now.iso8601}" Destination="#{@saml_slo_acs_url}" Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified" InResponseTo="#{@saml_slo_request_id}" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">#{issuer_uri}</Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status>#{assertion_and_signature}</samlp:LogoutResponse>]

Base64.encode64(xml)
end
end
10 changes: 8 additions & 2 deletions spec/support/sp_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
AUTHENTICATE
}
insert_into_file('app/views/home/index.html.erb', after: /\z/) {
"<%= current_user.email %> <%= current_user.name %>"
<<-HOME
<%= current_user.email %> <%= current_user.name %>
<%= form_tag destroy_user_session_path, method: :delete do %>
<%= submit_tag "Log out" %>
<% end %>
HOME
}
route "root to: 'home#index'"

Expand All @@ -33,6 +38,7 @@
config.saml_configure do |settings|
settings.assertion_consumer_service_url = "http://localhost:8020/users/saml/auth"
settings.issuer = "http://localhost:8020/saml/metadata"
settings.idp_slo_target_url = "http://localhost:8009/saml/logout"
settings.idp_sso_target_url = "http://localhost:8009/saml/auth"
settings.idp_cert_fingerprint = "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D"
end
Expand All @@ -41,7 +47,7 @@

generate :devise, "user", "email:string", "name:string"
gsub_file 'app/models/user.rb', /database_authenticatable.*\n.*/, 'saml_authenticatable'
route "resources :users"
route "resources :users, only: [:create]"
create_file('app/controllers/users_controller.rb', <<-USERS)
class UsersController < ApplicationController
skip_before_filter :verify_authenticity_token
Expand Down