diff --git a/Appraisals b/Appraisals
index 7208cd4..d686ec7 100644
--- a/Appraisals
+++ b/Appraisals
@@ -1,5 +1,6 @@
appraise 'rack-1' do
gem 'rack', '~> 1.x'
+ gem 'term-ansicolor', '1.3.2'
end
appraise 'rack-2' do
diff --git a/README.md b/README.md
index cec5586..9f27f7e 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,14 @@ The service provider metadata used to ease configuration of the SAML SP in the I
* `:idp_sso_target_url` - The URL to which the authentication request should be sent.
This would be on the identity provider. **Required**.
+* `:idp_slo_target_url` - The URL to which the single logout request and response should
+ be sent. This would be on the identity provider. Optional.
+
+* `:slo_default_relay_state` - The value to use as default `RelayState` for single log outs. The
+ value can be a string, or a `Proc` (or other object responding to `call`). The `request`
+ instance will be passed to this callable if it has an arity of 1. If the value is a string,
+ the string will be returned, when the `RelayState` is called. Optional.
+
* `:idp_sso_target_url_runtime_params` - A dynamic mapping of request params that exist
during the request phase of OmniAuth that should to be sent to the IdP after a specific
mapping. So for example, a param `original_request_param` with value `original_param_value`,
@@ -145,6 +153,35 @@ end
Then follow Devise's general [OmniAuth tutorial](https://github.com/plataformatec/devise/wiki/OmniAuth:-Overview), replacing references to `facebook` with `saml`.
+## Single Logout
+
+Single Logout can be Service Provider initiated or Identity Provider initiated.
+When using Devise as an authentication solution, the SP initiated flow can be integrated
+in the `SessionsController#destroy` action.
+
+For this to work it is important to preserve the `saml_uid` value before Devise
+clears the session and redirect to the `/spslo` sub-path to initiate the single logout.
+
+Example `destroy` action in `sessions_controller.rb`:
+
+```ruby
+class SessionsController < Devise::SessionsController
+ # ...
+
+ def destroy
+ # Preserve the saml_uid in the session
+ saml_uid = session["saml_uid"]
+ super do
+ session["saml_uid"] = saml_uid
+ if SAML_SETTINGS.idp_slo_target_url
+ spslo_url = user_omniauth_authorize_url(:saml) + "/spslo"
+ redirect_to(spslo_url)
+ end
+ end
+ end
+end
+```
+
## Authors
Authored by [Rajiv Aaron Manglani](http://www.rajivmanglani.com/), Raecoo Cao, Todd W Saxton, Ryan Wilcox, Steven Anderson, Nikos Dimitrakopoulos, Rudolf Vriend and [Bruno Pedro](http://brunopedro.com/).
diff --git a/gemfiles/rack_1.gemfile b/gemfiles/rack_1.gemfile
index ddf3a7e..c9c3b7e 100644
--- a/gemfiles/rack_1.gemfile
+++ b/gemfiles/rack_1.gemfile
@@ -4,6 +4,7 @@ source "https://rubygems.org"
gem "appraisal"
gem "rack", "~> 1.x"
+gem "term-ansicolor", "1.3.2"
group :test do
gem "coveralls", "~> 0.8", ">= 0.8.13", :require => false
diff --git a/lib/omniauth/strategies/saml.rb b/lib/omniauth/strategies/saml.rb
index d2f1d05..d2e5ac7 100644
--- a/lib/omniauth/strategies/saml.rb
+++ b/lib/omniauth/strategies/saml.rb
@@ -27,15 +27,19 @@ def self.inherited(subclass)
first_name: ["first_name", "firstname", "firstName"],
last_name: ["last_name", "lastname", "lastName"]
}
+ option :slo_default_relay_state
def request_phase
options[:assertion_consumer_service_url] ||= callback_url
runtime_request_parameters = options.delete(:idp_sso_target_url_runtime_params)
additional_params = {}
- runtime_request_parameters.each_pair do |request_param_key, mapped_param_key|
- additional_params[mapped_param_key] = request.params[request_param_key.to_s] if request.params.has_key?(request_param_key.to_s)
- end if runtime_request_parameters
+
+ if runtime_request_parameters
+ runtime_request_parameters.each_pair do |request_param_key, mapped_param_key|
+ additional_params[mapped_param_key] = request.params[request_param_key.to_s] if request.params.has_key?(request_param_key.to_s)
+ end
+ end
authn_request = OneLogin::RubySaml::Authrequest.new
settings = OneLogin::RubySaml::Settings.new(options)
@@ -44,9 +48,7 @@ def request_phase
end
def callback_phase
- unless request.params['SAMLResponse']
- raise OmniAuth::Strategies::SAML::ValidationError.new("SAML response missing")
- end
+ raise OmniAuth::Strategies::SAML::ValidationError.new("SAML response missing") unless request.params["SAMLResponse"]
# Call a fingerprint validation method if there's one
if options.idp_cert_fingerprint_validator
@@ -59,30 +61,21 @@ def callback_phase
end
settings = OneLogin::RubySaml::Settings.new(options)
+
# filter options to select only extra parameters
opts = options.select {|k,_| OTHER_REQUEST_OPTIONS.include?(k.to_sym)}
+
# symbolize keys without activeSupport/symbolize_keys (ruby-saml use symbols)
opts =
opts.inject({}) do |new_hash, (key, value)|
new_hash[key.to_sym] = value
new_hash
end
- response = OneLogin::RubySaml::Response.new(request.params['SAMLResponse'], opts.merge(settings: settings))
- response.attributes['fingerprint'] = options.idp_cert_fingerprint
-
- # will raise an error since we are not in soft mode
- response.soft = false
- response.is_valid?
-
- @name_id = response.name_id
- @attributes = response.attributes
- @response_object = response
- if @name_id.nil? || @name_id.empty?
- raise OmniAuth::Strategies::SAML::ValidationError.new("SAML response missing 'name_id'")
+ handle_response(request.params["SAMLResponse"], opts, settings) do
+ super
end
- super
rescue OmniAuth::Strategies::SAML::ValidationError
fail!(:invalid_ticket, $!)
rescue OneLogin::RubySaml::ValidationError
@@ -91,7 +84,7 @@ def callback_phase
# Obtain an idp certificate fingerprint from the response.
def response_fingerprint
- response = request.params['SAMLResponse']
+ response = request.params["SAMLResponse"]
response = (response =~ /^) ? response : Base64.decode64(response)
document = XMLSecurity::SignedDocument::new(response)
cert_element = REXML::XPath.first(document, "//ds:X509Certificate", { "ds"=> 'http://www.w3.org/2000/09/xmldsig#' })
@@ -101,26 +94,43 @@ def response_fingerprint
Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(':')
end
- def on_metadata_path?
- on_path?("#{request_path}/metadata")
- end
-
def other_phase
- if on_metadata_path?
- # omniauth does not set the strategy on the other_phase
+ if current_path.start_with?(request_path)
@env['omniauth.strategy'] ||= self
setup_phase
-
- response = OneLogin::RubySaml::Metadata.new
settings = OneLogin::RubySaml::Settings.new(options)
- if options.request_attributes.length > 0
- settings.attribute_consuming_service.service_name options.attribute_service_name
- settings.issuer = options.issuer
- options.request_attributes.each do |attribute|
- settings.attribute_consuming_service.add_attribute attribute
+
+ if on_subpath?(:metadata)
+ # omniauth does not set the strategy on the other_phase
+ response = OneLogin::RubySaml::Metadata.new
+
+ if options.request_attributes.length > 0
+ settings.attribute_consuming_service.service_name options.attribute_service_name
+ settings.issuer = options.issuer
+
+ options.request_attributes.each do |attribute|
+ settings.attribute_consuming_service.add_attribute attribute
+ end
+ end
+
+ Rack::Response.new(response.generate(settings), 200, { "Content-Type" => "application/xml" }).finish
+ elsif on_subpath?(:slo)
+ if request.params["SAMLResponse"]
+ handle_logout_response(request.params["SAMLResponse"], settings)
+ elsif request.params["SAMLRequest"]
+ handle_logout_request(request.params["SAMLRequest"], settings)
+ else
+ raise OmniAuth::Strategies::SAML::ValidationError.new("SAML logout response/request missing")
+ end
+ elsif on_subpath?(:spslo)
+ if options.idp_slo_target_url
+ redirect(generate_logout_request(settings))
+ else
+ Rack::Response.new("Not Implemented", 501, { "Content-Type" => "text/html" }).finish
end
+ else
+ call_app!
end
- Rack::Response.new(response.generate(settings), 200, { "Content-Type" => "application/xml" }).finish
else
call_app!
end
@@ -146,6 +156,94 @@ def find_attribute_by(keys)
nil
end
+
+ private
+
+ def on_subpath?(subpath)
+ on_path?("#{request_path}/#{subpath}")
+ end
+
+ def handle_response(raw_response, opts, settings)
+ response = OneLogin::RubySaml::Response.new(raw_response, opts.merge(settings: settings))
+ response.attributes["fingerprint"] = options.idp_cert_fingerprint
+ response.soft = false
+
+ response.is_valid?
+ @name_id = response.name_id
+ @attributes = response.attributes
+ @response_object = response
+
+ if @name_id.nil? || @name_id.empty?
+ raise OmniAuth::Strategies::SAML::ValidationError.new("SAML response missing 'name_id'")
+ end
+
+ session["saml_uid"] = @name_id
+ yield
+ end
+
+ def slo_relay_state
+ if request.params.has_key?("RelayState") && request.params["RelayState"] != ""
+ request.params["RelayState"]
+ else
+ slo_default_relay_state = options.slo_default_relay_state
+ if slo_default_relay_state.respond_to?(:call)
+ if slo_default_relay_state.arity == 1
+ slo_default_relay_state.call(request)
+ else
+ slo_default_relay_state.call
+ end
+ else
+ slo_default_relay_state
+ end
+ end
+ end
+
+ def handle_logout_response(raw_response, settings)
+ # After sending an SP initiated LogoutRequest to the IdP, we need to accept
+ # the LogoutResponse, verify it, then actually delete our session.
+
+ logout_response = OneLogin::RubySaml::Logoutresponse.new(raw_response, settings, :matches_request_id => session["saml_transaction_id"])
+ logout_response.soft = false
+ logout_response.validate
+
+ session.delete("saml_uid")
+ session.delete("saml_transaction_id")
+
+ redirect(slo_relay_state)
+ end
+
+ def handle_logout_request(raw_request, settings)
+ logout_request = OneLogin::RubySaml::SloLogoutrequest.new(raw_request)
+
+ if logout_request.is_valid? &&
+ logout_request.name_id == session["saml_uid"]
+
+ # Actually log out this session
+ session.clear
+
+ # Generate a response to the IdP.
+ logout_request_id = logout_request.id
+ logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request_id, nil, RelayState: slo_relay_state)
+ redirect(logout_response)
+ else
+ raise OmniAuth::Strategies::SAML::ValidationError.new("SAML failed to process LogoutRequest")
+ end
+ end
+
+ # Create a SP initiated SLO: https://github.com/onelogin/ruby-saml#single-log-out
+ def generate_logout_request(settings)
+ logout_request = OneLogin::RubySaml::Logoutrequest.new()
+
+ # Since we created a new SAML request, save the transaction_id
+ # to compare it with the response we get back
+ session["saml_transaction_id"] = logout_request.uuid
+
+ if settings.name_identifier_value.nil?
+ settings.name_identifier_value = session["saml_uid"]
+ end
+
+ logout_request.create(settings, RelayState: slo_relay_state)
+ end
end
end
end
diff --git a/spec/omniauth/strategies/saml_spec.rb b/spec/omniauth/strategies/saml_spec.rb
index 5ce4b79..85eeffb 100644
--- a/spec/omniauth/strategies/saml_spec.rb
+++ b/spec/omniauth/strategies/saml_spec.rb
@@ -6,8 +6,8 @@
end
end
-def post_xml(xml=:example_response)
- post "/auth/saml/callback", {'SAMLResponse' => load_xml(xml)}
+def post_xml(xml=:example_response, opts = {})
+ post "/auth/saml/callback", opts.merge({'SAMLResponse' => load_xml(xml)})
end
describe OmniAuth::Strategies::SAML, :type => :strategy do
@@ -17,7 +17,9 @@ def post_xml(xml=:example_response)
let(:saml_options) do
{
:assertion_consumer_service_url => "http://localhost:9080/auth/saml/callback",
+ :single_logout_service_url => "http://localhost:9080/auth/saml/slo",
:idp_sso_target_url => "https://idp.sso.example.com/signon/29490",
+ :idp_slo_target_url => "https://idp.sso.example.com/signoff/29490",
:idp_cert_fingerprint => "C1:59:74:2B:E8:0C:6C:A9:41:0F:6E:83:F6:D1:52:25:45:58:89:FB",
:idp_sso_target_url_runtime_params => {:original_param_key => :mapped_param_key},
:name_identifier_format => "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
@@ -152,6 +154,7 @@ def post_xml(xml=:example_response)
context "when there is no name id in the XML" do
before :each do
+ allow(Time).to receive(:now).and_return(Time.utc(2012, 11, 8, 23, 55, 00))
post_xml :no_name_id
end
@@ -203,6 +206,72 @@ def post_xml(xml=:example_response)
)
end
end
+
+ context "when response is a logout response" do
+ before :each do
+ saml_options[:issuer] = "https://idp.sso.example.com/metadata/29490"
+
+ post "/auth/saml/slo", {
+ SAMLResponse: load_xml(:example_logout_response),
+ RelayState: "https://example.com/",
+ }, "rack.session" => {"saml_transaction_id" => "_3fef1069-d0c6-418a-b68d-6f008a4787e9"}
+ end
+ it "should redirect to relaystate" do
+ expect(last_response).to be_redirect
+ expect(last_response.location).to match /https:\/\/example.com\//
+ end
+ end
+
+ context "when request is a logout request" do
+ before :each do
+ saml_options[:issuer] = "https://idp.sso.example.com/metadata/29490"
+ post "/auth/saml/slo", {
+ "SAMLRequest" => load_xml(:example_logout_request),
+ "RelayState" => "https://example.com/",
+ }, "rack.session" => {"saml_uid" => "username@example.com"}
+ end
+
+ it "should redirect to logout response" do
+ expect(last_response).to be_redirect
+ expect(last_response.location).to match /https:\/\/idp.sso.example.com\/signoff\/29490/
+ expect(last_response.location).to match /RelayState=https%3A%2F%2Fexample.com%2F/
+ end
+ end
+
+ context "when sp initiated SLO" do
+ def test_default_relay_state(static_default_relay_state = nil, &block_default_relay_state)
+ saml_options["slo_default_relay_state"] = static_default_relay_state || block_default_relay_state
+ post "/auth/saml/spslo"
+
+ expect(last_response).to be_redirect
+ expect(last_response.location).to match /https:\/\/idp.sso.example.com\/signoff\/29490/
+ expect(last_response.location).to match /RelayState=https%3A%2F%2Fexample.com%2F/
+ end
+
+ it "should redirect to logout request" do
+ test_default_relay_state("https://example.com/")
+ end
+
+ it "should redirect to logout request with a block" do
+ test_default_relay_state do
+ "https://example.com/"
+ end
+ end
+
+ it "should redirect to logout request with a block with a request parameter" do
+ test_default_relay_state do |request|
+ "https://example.com/"
+ end
+ end
+
+ it "should give not implemented without an idp_slo_target_url" do
+ saml_options.delete(:idp_slo_target_url)
+ post "/auth/saml/spslo"
+
+ expect(last_response.status).to eq 501
+ expect(last_response.body).to match /Not Implemented/
+ end
+ end
end
describe 'GET /auth/saml/metadata' do
@@ -226,10 +295,6 @@ def post_xml(xml=:example_response)
end
end
- it 'implements #on_metadata_path?' do
- expect(described_class.new(nil)).to respond_to(:on_metadata_path?)
- end
-
describe 'subclass behavior' do
it 'registers subclasses in OmniAuth.strategies' do
subclass = Class.new(described_class)
diff --git a/spec/support/example_logout_request.xml b/spec/support/example_logout_request.xml
new file mode 100644
index 0000000..9fd2a07
--- /dev/null
+++ b/spec/support/example_logout_request.xml
@@ -0,0 +1,5 @@
+
+
+ https://idp.sso.example.com/metadata/29490
+ username@example.com
+
diff --git a/spec/support/example_logout_response.xml b/spec/support/example_logout_response.xml
new file mode 100644
index 0000000..d9e205a
--- /dev/null
+++ b/spec/support/example_logout_response.xml
@@ -0,0 +1,8 @@
+
+
+ https://idp.sso.example.com/metadata/29490
+
+
+ Successfully logged out from service
+
+