From cd3fc4344547c19d7f7061fe78da1f6af965b970 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Sun, 18 Sep 2016 11:46:56 -0500 Subject: [PATCH] feat: Support for Single Logout --- Appraisals | 1 + README.md | 37 +++++ gemfiles/rack_1.gemfile | 1 + lib/omniauth/strategies/saml.rb | 166 ++++++++++++++++++----- spec/omniauth/strategies/saml_spec.rb | 77 ++++++++++- spec/support/example_logout_request.xml | 5 + spec/support/example_logout_response.xml | 8 ++ 7 files changed, 255 insertions(+), 40 deletions(-) create mode 100644 spec/support/example_logout_request.xml create mode 100644 spec/support/example_logout_response.xml 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 =~ /^ '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 + +