rack_entra_id_auth 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49047b15ce1a63b8d78470a9f3470642bc1097ef848af57c588a105583ac5c08
4
+ data.tar.gz: 831a559681d4e4f17b837796bc670d93bbec3c148ac0f9ad868a72a2631df53c
5
+ SHA512:
6
+ metadata.gz: b6e06a56cad6e71aa29e1191cec1a5e6d1c3f3a6c1080d2ed2fd2291de3af5ad3c0a41de393b72c26fb117b42490269a6c7b2eaeb65cb4e03fbf6a9cb5ee0ca5
7
+ data.tar.gz: a685bb6f5f8dae75f9153acfcb403594f8be4cd1ab1621b7abca4cbf97a04a55b53d4ce70c10a7b0803587816c3fbd6732d24b9ce65ebebbd1bdd62835ad9ba1
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024 David Susco
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # EntraIdActiveRecordSessionStore
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/entra_id_active_record_session_store`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Development
24
+
25
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
26
+
27
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
28
+
29
+ ## Contributing
30
+
31
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/entra_id_active_record_session_store.
32
+
33
+ ## License
34
+
35
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'minitest/test_task'
3
+
4
+ Minitest::TestTask.create
5
+
6
+ require 'rubocop/rake_task'
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[test rubocop]
@@ -0,0 +1,13 @@
1
+ module RackEntraIdAuth
2
+ module Generators
3
+ class InitializerGenerator < Rails::Generators::Base
4
+ desc 'Create an initializer to configure RackEntraIdAuth.'
5
+
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ def create_initializer
9
+ template 'config_initializer.rb', Rails.root.join('config', 'initializers', 'rack_entra_id_auth.rb')
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ module RackEntraIdAuth
2
+ module Generators
3
+ class RoutesGenerator < Rails::Generators::Base
4
+ desc 'Create route helpers for single sign-on/logout requests handled by the RackEntraIdAuth middleware.'
5
+
6
+ def create_login_route
7
+ route "get '#{RackEntraIdAuth.config.login_path}', as: :login, to: ->(env) { [204, {}, ['']] }"
8
+ end
9
+
10
+ def create_logout_route
11
+ route "get '#{RackEntraIdAuth.config.logout_path}', as: :logout, to: ->(env) { [204, {}, ['']] }"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,169 @@
1
+ RackEntraIdAuth.configure do |config|
2
+ # Ruby SAML needs to be configured with the Entra ID application it will using
3
+ # as the Identify Provider (IdP) as well as your application that it will be
4
+ # using as the Service Provider (SP).
5
+ #
6
+ # All of the Ruby SAML settings are exposed as configuration attributes on the
7
+ # RackEntraIdAuth.config object and can be set from within this initializer or
8
+ # within `config/application.rb' or the environment-specific configuration
9
+ # files (e.g. config.rack_entra_id_auth.idp_entity_id = '…'). Any default
10
+ # settings defined by Ruby SAML are used by RackEntraIdAuth and called out
11
+ # below. They can also be found here:
12
+ # https://github.com/SAML-Toolkits/ruby-saml/blob/master/lib/onelogin/ruby-saml/settings.rb#L276
13
+
14
+ # ------------------------
15
+ # RackEntraIdAuth Settings
16
+ # ------------------------
17
+
18
+ # The login/logout paths are used by the middleware to create single
19
+ # sign-on/logout requests and redirect them to the IdP SSO/SLO Service URLs
20
+ # set below, respectively. It's handy to define these as routes in your
21
+ # application so you can use the route helpers in your views. You can do so
22
+ # with the `rack_entra_id_auth:routes` generator.
23
+ # config.login_path = '/login'
24
+ # config.logout_path = '/logout'
25
+
26
+ # By default, all the login/logout responses from the IdP are redirected to
27
+ # base URL of your application after the response is successfully processed
28
+ # and the user's session is set/deleted. If you'd like the user redirected to
29
+ # another URL upon successful login/logout this can be set below. These can
30
+ # also be overridden on a per-request basis by adding a `relay_state` query
31
+ # parameter to requests for the login/logout paths above, e.g.
32
+ # `http://your:app/login?relay_state=https%3A%2F%2Fyour.app%2Fgo_here_instead`
33
+ # config.login_relay_state_url = ''
34
+ # config.logout_relay_state_url = ''
35
+
36
+ # ----------------------------------------------------------------------------
37
+ # THIS SETTING HAS NO EFFECT IN AN INITIALIZER (it's set too late). It needs
38
+ # to be in `config/application.rb` or an environment-specific configuration
39
+ # file. You'll likely want to put it in in your
40
+ # `config/environments/production.rb` file as
41
+ # `config.rack_entra_id_auth.mock_server = false`.
42
+ # ----------------------------------------------------------------------------
43
+ # config.mock_server = false
44
+
45
+ # If mock_server is enabled these are the usernames you'll be able to log in
46
+ # as with the mock middleware, as well as the assoicatied "SAML" attributes
47
+ # that will be placed in the user's session.
48
+ # config.mock_attributes = {
49
+ # 'rtables' => {
50
+ # 'displayname' => 'Little Bobby Tables',
51
+ # 'groups' => ['Students'],
52
+ # 'givenname' => "Robert');DROP TABLE Students;-- ?",
53
+ # 'surname' => 'Tables',
54
+ # 'emailaddress' => 'rtables@your.app',
55
+ # 'name' => 'rtables@your.app'
56
+ # },
57
+ # 'badmin' => {
58
+ # 'displayname' => 'Bad Admin',
59
+ # 'groups' => ['Admins'],
60
+ # 'givenname' => 'Bad',
61
+ # 'surname' => 'Admin',
62
+ # 'emailaddress' => 'badmin@your.app',
63
+ # 'name' => 'badmin@your.app'
64
+ # }
65
+ # }
66
+
67
+ # The hash key the SAML attributes are stored under in the sessions hash.
68
+ # config.session_key = :entra_id
69
+
70
+ # The SAML attributes can be modified before they are stored within the user's
71
+ # session via the proc below. The following is the default proc.
72
+ # config.session_value_proc = Proc.new { |attributes|
73
+ # attributes.inject({}) do |memo, (key, value)|
74
+ # key = key.split('/').last
75
+ # value = value.first if value.kind_of?(Array) and value.length.eql?(1) and !key.eql?('groups')
76
+ # memo[key] = value
77
+ # memo
78
+ # end
79
+ # }
80
+
81
+ # Single logout requires changing the application's session store to work, so
82
+ # it is disabled by default. Once the session store is configured uncomment
83
+ # the line below to enable single logout.
84
+ # config.skip_single_logout = true
85
+
86
+ # ----------------------
87
+ # Ruby SAML IdP Settings
88
+ # ----------------------
89
+
90
+ # When the Entra ID application is set up you'll be provided with single sign-
91
+ # on/logout service URLs. RackEntraIdAuth needs these so it can direct the
92
+ # single sign-on/logout requests that originate from within your application.
93
+ # The public certificate provided by the IdP also needs to be in these
94
+ # requests and should be set below as well.
95
+
96
+ # config.idp_entity_id = ''
97
+ config.idp_sso_service_url = "IdP SINGLE SIGN-ON SERVICE URL"
98
+ config.idp_slo_service_url = "IdP SINGLE LOGOUT SERVICE URL"
99
+ # config.idp_slo_response_service_url = ''
100
+ config.idp_cert = 'IdP X509CERTIFICATE'
101
+ # config.idp_cert_fingerprint = ''
102
+ # config.idp_cert_fingerprint_algorithm = XMLSecurity::Document::SHA1
103
+ # config.idp_cert_multi = ''
104
+ # config.idp_attribute_names = ''
105
+ # config.idp_name_qualifier = ''
106
+ # config.valid_until = ''
107
+
108
+ # ---------------------
109
+ # Ruby SAML SP Settings
110
+ # ---------------------
111
+
112
+ # When the Entra ID application is set up you'll need to provide some way to
113
+ # identify your application (the SP) to it as well as the URLs your
114
+ # application will use to handle single sign-on/logout reseponses sent by the
115
+ # IdP to your application. Your application does not need to be aware of these
116
+ # URLs, the RackEntraIdAuth middleware will intercept and handle them.
117
+ # However, these URLs should not conflict with any routes within your
118
+ # application as requests sent to them will never make it to your application.
119
+
120
+ config.sp_entity_id = 'https://your.app/'
121
+ config.assertion_consumer_service_url = 'https://your.app/saml/login'
122
+ config.single_logout_service_url = 'https://your.app/saml/logout'
123
+ # config.sp_name_qualifier = ''
124
+ # config.name_identifier_format = ''
125
+ # config.name_identifier_value = ''
126
+ # config.name_identifier_value_requested = ''
127
+ # config.sessionindex = ''
128
+ # Ruby SAML normally compresses requests/responses and double quotes the XML
129
+ # attributes. Uncomment the lines below to change that.
130
+ # config.compress_request = false
131
+ # config.compress_response = false
132
+ # config.double_quote_xml_attribute_values = false
133
+ # config.message_max_bytesize = 250000
134
+ # config.passive = ''
135
+ # config.attributes_index = ''
136
+ # config.force_authn = ''
137
+ # config.certificate = ''
138
+ # config.private_key = ''
139
+ # config.sp_cert_multi = ''
140
+ # config.authn_context = ''
141
+ # config.authn_context_comparison = ''
142
+ # config.authn_context_decl_ref = ''
143
+
144
+ # ---------------------------
145
+ # Ruby SAML Workflow Settings
146
+ # ---------------------------
147
+
148
+ # The default Ruby SAML security configurations can be overriden by
149
+ # uncommenting the lines below.
150
+ # config.security = {
151
+ # :authn_requests_signed => true,
152
+ # :logout_requests_signed => true,
153
+ # :logout_responses_signed => true,
154
+ # :want_assertions_signed => true,
155
+ # :want_assertions_encrypted => true,
156
+ # :want_name_id => true,
157
+ # :metadata_signed => true,
158
+ # :digest_method => XMLSecurity::Document::SHA1,
159
+ # :signature_method => XMLSecurity::Document::RSA_SHA1,
160
+ # :check_idp_cert_expiration => true,
161
+ # :check_sp_cert_expiration => true,
162
+ # :strict_audience_validation => true,
163
+ # :lowercase_url_encoding => true
164
+ # }
165
+
166
+ # Ruby SAML normally doesn't raise SAML validation errors, uncomment the line
167
+ # below to raise them.
168
+ # config.soft = false
169
+ end
@@ -0,0 +1,80 @@
1
+ require 'active_support/configurable'
2
+
3
+ module RackEntraIdAuth
4
+ class Configuration
5
+ include ActiveSupport::Configurable
6
+
7
+ config_accessor :login_path, default: '/login'
8
+ config_accessor :login_relay_state_url
9
+ config_accessor :logout_path, default: '/logout'
10
+ config_accessor :logout_relay_state_url
11
+ # mock_server must be set in `config/application.rb` or an environment-
12
+ # specific configuration file. I.e. it must happen before initializers as
13
+ # it's used in the initializer created in the Railtie.
14
+ config_accessor :mock_server, default: true
15
+ config_accessor :mock_attributes, default: {}
16
+ config_accessor :session_key, default: :entra_id
17
+ config_accessor :session_value_proc, default: Proc.new { |attributes|
18
+ attributes.inject({}) do |memo, (key, value)|
19
+ key = key.split('/').last
20
+ value = value.first if value.kind_of?(Array) and value.length.eql?(1) and !key.eql?('groups')
21
+ memo[key] = value
22
+ memo
23
+ end
24
+ }
25
+ config_accessor :skip_single_logout, default: true
26
+
27
+ # Ruby SAML ID Provider Settings
28
+ config_accessor :idp_entity_id
29
+ config_accessor :idp_sso_service_url
30
+ config_accessor :idp_slo_service_url
31
+ config_accessor :idp_slo_response_service_url
32
+ config_accessor :idp_cert
33
+ config_accessor :idp_cert_fingerprint
34
+ config_accessor :idp_cert_fingerprint_algorithm
35
+ config_accessor :idp_cert_multi
36
+ config_accessor :idp_attribute_names
37
+ config_accessor :idp_name_qualifier
38
+ config_accessor :valid_until
39
+
40
+ # Ruby SAML Service Provider Settings
41
+ config_accessor :sp_entity_id
42
+ config_accessor :assertion_consumer_service_url
43
+ config_accessor :single_logout_service_url
44
+ config_accessor :sp_name_qualifier
45
+ config_accessor :name_identifier_format
46
+ config_accessor :name_identifier_value
47
+ config_accessor :name_identifier_value_requested
48
+ config_accessor :sessionindex
49
+ config_accessor :compress_request
50
+ config_accessor :compress_response
51
+ config_accessor :double_quote_xml_attribute_values
52
+ config_accessor :message_max_bytesize
53
+ config_accessor :passive
54
+ config_accessor :attributes_index
55
+ config_accessor :force_authn
56
+ config_accessor :certificate
57
+ config_accessor :private_key
58
+ config_accessor :sp_cert_multi
59
+ config_accessor :authn_context
60
+ config_accessor :authn_context_comparison
61
+ config_accessor :authn_context_decl_ref
62
+
63
+ # Ruby SAML workflow Settings
64
+ config_accessor :security
65
+ config_accessor :soft
66
+
67
+ def ruby_saml_settings
68
+ config.to_h.except(
69
+ :login_path,
70
+ :login_relay_state_url,
71
+ :logout_path,
72
+ :logout_relay_state_url,
73
+ :mock_server,
74
+ :mock_attributes,
75
+ :session_key,
76
+ :session_value_proc,
77
+ :skip_single_logout)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,240 @@
1
+ require 'ruby-saml'
2
+
3
+ module RackEntraIdAuth
4
+ class EntraIdRequest
5
+ attr_reader :request
6
+
7
+ def initialize(request, saml_setting_overrides = {})
8
+ @request = request
9
+
10
+ @saml_settings = OneLogin::RubySaml::Settings.new(RackEntraIdAuth.config.ruby_saml_settings.merge(saml_setting_overrides))
11
+ end
12
+
13
+ # Returns the request's base URL and path without the path_info at the end.
14
+ #
15
+ # @return [String]
16
+ #
17
+ def base_url
18
+ "#{request.base_url}#{request.path}".sub(Regexp.new("#{request.path_info}$"), '')
19
+ end
20
+
21
+ # Returns whether the request is a Service Provider initiated sign-on
22
+ # request. Returns true if the request's path info equals the login path
23
+ # configuration (login_path), otherwise returns false.
24
+ #
25
+ # @return [Bool]
26
+ #
27
+ def login?
28
+ request.path_info.eql?(RackEntraIdAuth.config.login_path)
29
+ end
30
+
31
+ # Returns whether the request contains a single sign-on response (for
32
+ # Service Provider initiated single sign-on requests). Returns true if the
33
+ # request's header contains a SAMLResponse and if the request's base_url and
34
+ # path match the ACS service url setting (assertion_consumer_service_url),
35
+ # otherwise returns false.
36
+ #
37
+ # @return [Bool]
38
+ #
39
+ def login_response?
40
+ saml_response.present? and "#{request.base_url}#{request.path}".eql?(@saml_settings.assertion_consumer_service_url)
41
+ end
42
+
43
+ # Returns whether the request is a Service Provider initiated logout
44
+ # request. Returns true if the request's path info equals the logout path
45
+ # configuration (logout_path), otherwise returns false.
46
+ #
47
+ # @return [Bool]
48
+ #
49
+ def logout?
50
+ request.path_info.eql?(RackEntraIdAuth.config.logout_path)
51
+ end
52
+
53
+ # Returns whether the request contains a single logout request (for ID
54
+ # Provider initiated single logout requests). Returns true if the request
55
+ # contains a SAMLRequest query parameter and if the request's base_url and
56
+ # path match the single logout service url setting
57
+ # (single_logout_service_url), otherwise returns false.
58
+ #
59
+ # @return [Bool]
60
+ #
61
+ def logout_request?
62
+ request.params['SAMLRequest'].present? and "#{request.base_url}#{request.path}".eql?(@saml_settings.single_logout_service_url)
63
+ end
64
+
65
+ # Returns whether the request contains a single logout response for Service
66
+ # Provider initiated logout request. Returns true if the request contains a
67
+ # SAMLResponse query parameter and if the request's base_url and path match
68
+ # the single logout service url setting (single_logout_service_url),
69
+ # otherwise returns false.
70
+ #
71
+ # @return [Bool]
72
+ #
73
+ def logout_response?
74
+ request.params['SAMLResponse'].present? and "#{request.base_url}#{request.path}".eql?(@saml_settings.single_logout_service_url)
75
+ end
76
+
77
+ # Returns the RelayState in the header of the request or its query
78
+ # parameters.
79
+ #
80
+ # @return [String]
81
+ #
82
+ def relay_state_url
83
+ request.get_header('rack.request.form_hash')['RelayState'] || request.params['RelayState']
84
+ end
85
+
86
+ # A single sign-on response for the SAMLResponse in the request's header.
87
+ # This is the response sent by the ID Provider for Service Provider
88
+ # initiated single sign-on requests.
89
+ #
90
+ # @param auth_request_id [String] If provided, check that the inResponseTo
91
+ # in the response matches the uuid of the sign-on request that
92
+ # initiated the response.
93
+ # @param skip_conditions [Bool] Skip the conditions validation.
94
+ # @param allowed_clock_drift [Float] The allowed clock drift when checking
95
+ # time stamps.
96
+ # @param skip_subject_confirmation [Bool] Skip the subject confirmation
97
+ # validation.
98
+ # @param skip_recipient_check [Bool] Skip the recipient validation of the
99
+ # subject confirmation element.
100
+ # @param skip_audience [Bool] Skip the audience validation.
101
+ #
102
+ # @return [OneLogin::RubySaml::Response] A single sign-on response for a
103
+ # Service Provideer initiated single sign-on request.
104
+ #
105
+ def saml_auth_response (auth_request_id: request.session[:auth_request_id], skip_conditions: false, allowed_clock_drift: nil, skip_subject_confirmation: false, skip_recipient_check: false, skip_audience: false)
106
+ response = OneLogin::RubySaml::Response.new(
107
+ saml_response,
108
+ { :settings => @saml_settings,
109
+ :matches_request_id => auth_request_id,
110
+ :skip_conditions => skip_conditions,
111
+ :allowed_clock_drift => allowed_clock_drift,
112
+ :skip_subject_confirmation => skip_subject_confirmation,
113
+ :skip_recipient_check => skip_recipient_check,
114
+ :skip_audience => skip_audience })
115
+
116
+ # the auth request's ID is no longer needed
117
+ request.session.delete(:auth_request_id)
118
+
119
+ response
120
+ end
121
+
122
+ # A single logout request for the SAMLRequest in the request's query
123
+ # parameters. This is the request sent by the ID Provider for ID Provider
124
+ # initiated single logout requests.
125
+ #
126
+ # @param allowed_clock_drift [Float] The allowed clock drift when checking
127
+ # time stamps.
128
+ # @param relax_signature_validation [Bool] If true and there's no ID
129
+ # Provider certs in the settings then ignore the signature validation
130
+ # on the request.
131
+ #
132
+ # @return [OneLogin::RubySaml::Logoutresponse] A single logout response for
133
+ # a Service Provideer initiated single logout request.
134
+ #
135
+ def saml_logout_request (allowed_clock_drift: nil, relax_signature_validation: false)
136
+ OneLogin::RubySaml::SloLogoutrequest.new(
137
+ request.params['SAMLRequest'],
138
+ { :settings => @saml_settings,
139
+ :allowed_clock_drift => allowed_clock_drift,
140
+ :relax_signature_validation => relax_signature_validation })
141
+ end
142
+
143
+ # A single logout response for the SAMLResponse in the request's query
144
+ # parameters. This is the response sent by the ID Provider for Service
145
+ # Provider initiated single logout requests.
146
+ #
147
+ # @param logout_request_id [String] If provided, check that the inResponseTo
148
+ # in the response matches the uuid of the logout request that
149
+ # initiated the response.
150
+ # @param relax_signature_validation [Bool] If true and there's no ID
151
+ # Provider certs in the settings then ignore the signature validation
152
+ # on the response.
153
+ #
154
+ # @return [OneLogin::RubySaml::Logoutresponse] A single logout response for
155
+ # a Service Provideer initiated single logout request.
156
+ #
157
+ def saml_logout_response (logout_request_id: request.session[:logout_request_id], relax_signature_validation: false)
158
+ logout_response = OneLogin::RubySaml::Logoutresponse.new(
159
+ request.params['SAMLResponse'],
160
+ @saml_settings,
161
+ { :get_params => request.params,
162
+ :matches_request_id => logout_request_id,
163
+ :relax_signature_validation => relax_signature_validation })
164
+
165
+ # the logout request's ID is no longer needed
166
+ request.session.delete(:logout_request_id)
167
+
168
+ logout_response
169
+ end
170
+
171
+ # Returns a single logout reponse URL for the settings provided. Used for ID
172
+ # Provider initiated log outs.
173
+ #
174
+ # @param request_id [String] The ID of the LogoutRequest sent by this SP to
175
+ # the IdP. That ID will be placed as the InResponseTo in the logout
176
+ # response.
177
+ # @param logout_message [String] The message to be placed as StatusMessage
178
+ # in the logout response.
179
+ # @param params [Hash] Extra query parameters to be added to the URL (e.g.
180
+ # RelayState).
181
+ # @param logout_status_code [String] The StatusCode to be placed as
182
+ # StatusMessage in the logout response.
183
+ #
184
+ # @return [String]
185
+ #
186
+ def slo_response_url (request_id: nil, logout_message: nil, params: {}, logout_status_code: nil)
187
+ OneLogin::RubySaml::SloLogoutresponse.new.create(
188
+ @saml_settings,
189
+ request_id,
190
+ logout_message,
191
+ params,
192
+ logout_status_code)
193
+ end
194
+
195
+ # Returns a single logout request URL for the settings provided if an ID
196
+ # Provider single logout target URL is present in the settings
197
+ # (idp_slo_service_url), otherwise returns nil. Used for Service Provider
198
+ # initiated log outs.
199
+ #
200
+ # @param params [Hash] Extra query parameters to be added to the URL (e.g.
201
+ # RelayState).
202
+ #
203
+ # @return [String|nil]
204
+ #
205
+ def slo_url (params = {})
206
+ logout_request = OneLogin::RubySaml::Logoutrequest.new
207
+
208
+ if @saml_settings.idp_slo_service_url.present?
209
+ # store the logout request's uuid to validate it in the response
210
+ request.session[:logout_request_id] = logout_request.uuid
211
+
212
+ # return nil if no single logout url is set
213
+ logout_request.create(@saml_settings, params)
214
+ end
215
+ end
216
+
217
+ # Returns a single sign-on authentication request URL for the settings
218
+ # provided. Used for Service Provider initiated sign-ins.
219
+ #
220
+ # @param params [Hash] Extra query parameters to be added to the URL (e.g.
221
+ # RelayState).
222
+ #
223
+ # @return [String]
224
+ #
225
+ def sso_url (params = {})
226
+ auth_request = OneLogin::RubySaml::Authrequest.new
227
+
228
+ # store the auth request's uuid to validate it in the response
229
+ request.session[:auth_request_id] = auth_request.uuid
230
+
231
+ auth_request.create(@saml_settings, params)
232
+ end
233
+
234
+ private
235
+
236
+ def saml_response
237
+ request.get_header('rack.request.form_hash').try(:[], 'SAMLResponse')
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,192 @@
1
+ require 'rack_entra_id_auth/entra_id_request'
2
+
3
+ module RackEntraIdAuth
4
+ class Middleware
5
+ def initialize (app)
6
+ @app = app
7
+ end
8
+
9
+ def call (env)
10
+ request = Rack::Request.new(env)
11
+ entra_id_request = EntraIdRequest.new(request)
12
+
13
+ # SP initiated single sign-on request
14
+ if entra_id_request.login?
15
+ log(env, 'Redirecting login request to Entra ID single sign-on URL…')
16
+
17
+ sso_url = entra_id_request.sso_url(
18
+ { :RelayState => request.params['relay_state'] ||
19
+ RackEntraIdAuth.config.login_relay_state_url ||
20
+ entra_id_request.base_url })
21
+
22
+ return found_redirect_response(
23
+ sso_url,
24
+ 'Redirecting login request to Entra ID single sign-on URL')
25
+ end
26
+
27
+ # SP initiated logout/single logout request
28
+ if entra_id_request.logout?
29
+ log(env, 'Destroying session…')
30
+
31
+ # destroy session in case single logout fails
32
+ destroy_session()
33
+
34
+ relay_state_url = request.params['relay_state'] ||
35
+ RackEntraIdAuth.config.logout_relay_state_url ||
36
+ entra_id_request.base_url
37
+
38
+ slo_url = entra_id_request.slo_url({ :RelayState => relay_state_url })
39
+
40
+ if request.params['skip_single_logout'].blank? and
41
+ !RackEntraIdAuth.config.skip_single_logout and
42
+ slo_url.present?
43
+
44
+ log(env, 'Redirecting logout request to Entra ID single logout URL…')
45
+
46
+ return found_redirect_response(
47
+ slo_url,
48
+ 'Redirecting logout request to Entra ID single logout URL')
49
+ end
50
+
51
+ log(env, 'Skipping single logout because of skip_single_logout query parameter…') if request.params['skip_single_logout'].present?
52
+ log(env, 'Skipping single logout because of skip_single_logout configuration setting…') if RackEntraIdAuth.config.skip_single_logout
53
+ log(env, 'Skipping single logout because no Entra ID single logout URL was found…') if slo_url.blank?
54
+
55
+ log(env, 'Redirecting to relay state URL…')
56
+
57
+ return found_redirect_response(relay_state_url)
58
+ end
59
+
60
+ # SP initiatied single sign-on response
61
+ if entra_id_request.login_response?
62
+ log(env, 'Received single login response…')
63
+
64
+ auth_response = entra_id_request.saml_auth_response()
65
+
66
+ if !auth_response.is_valid?
67
+ log(env, "Invalid single login reponse from Entra ID: #{auth_response.errors.first}")
68
+
69
+ return internal_server_error_response("Invalid login reponse from Entra ID: #{auth_response.errors.first}")
70
+ end
71
+
72
+ if !auth_response.success?
73
+ log(env, 'Unsuccessful single single reponse from Entra ID.')
74
+
75
+ return internal_server_error_response('Unsuccessful login reponse from Entra ID.')
76
+ end
77
+
78
+ log(env, 'Initializing session and redirecting to relay state URL…')
79
+
80
+ # initialize the session with the response's SAML attributes
81
+ request.session[RackEntraIdAuth.config.session_key] = RackEntraIdAuth.config.session_value_proc.call(auth_response.attributes.all)
82
+
83
+ return found_redirect_response(
84
+ entra_id_request.relay_state_url,
85
+ 'Received single login response, redirecting to relay state URL')
86
+ end
87
+
88
+ # IdP initiatied single logout request
89
+ if entra_id_request.logout_request? and !RackEntraIdAuth.config.skip_single_logout
90
+ log(env, 'Received single logout request…')
91
+
92
+ logout_request = entra_id_request.saml_logout_request()
93
+
94
+ if !logout_request.is_valid?
95
+ log(env, "Invalid single logout request from Entra ID: #{logout_request.errors.first}")
96
+
97
+ return internal_server_error_response("Invalid logout request from Entra ID: #{logout_request.errors.first}")
98
+ end
99
+
100
+ log(env, 'Destroying session and sending logout response to Entra ID…')
101
+
102
+ # destroy the session
103
+ destroy_session()
104
+
105
+ response_url = entra_id_request.slo_response_url(
106
+ request_id: logout_request.id,
107
+ logout_message: nil,
108
+ params: {
109
+ :RelayState => entra_id_request.relay_state_url
110
+ },
111
+ logout_status_code: nil)
112
+
113
+ return found_redirect_response(
114
+ response_url,
115
+ 'Received single logout request, redirecting to Entra ID')
116
+ end
117
+
118
+ # SP initiated single logout response
119
+ if entra_id_request.logout_response?
120
+ log(env, 'Received single logout response…')
121
+
122
+ logout_response = entra_id_request.saml_logout_response()
123
+
124
+ if !logout_response.validate
125
+ log(env, "Invalid single logout reponse from Entra ID: #{logout_response.errors.first}")
126
+
127
+ return internal_server_error_response("Invalid logout reponse from Entra ID: #{logout_response.errors.first}")
128
+ end
129
+
130
+ if !logout_response.success?
131
+ log(env, 'Unsuccessful single logout reponse from Entra ID.')
132
+
133
+ return internal_server_error_response('Unsuccessful logout reponse from Entra ID.')
134
+ end
135
+
136
+ log(env, 'Destroying session and redirecting to relay state URL…')
137
+
138
+ # session should already be destroyed from SP initiated logout/single
139
+ # logout request, but just to be safe…
140
+ destroy_session()
141
+
142
+ return found_redirect_response(
143
+ entra_id_request.relay_state_url,
144
+ 'Received single logout response, redirecting to relay state URL')
145
+ end
146
+
147
+ response = @app.call(env)
148
+
149
+ # Authenticate 401s
150
+ if response[0] == 401
151
+ log(env, 'Intercepted 401 Unauthorized response, redirecting to Entra ID single sign-on URL…')
152
+
153
+ return found_redirect_response(
154
+ entra_id_request.sso_url(:RelayState => request.url),
155
+ 'Intercepted 401 Unauthorized response, redirecting to Entra ID single sign-on URL')
156
+ end
157
+
158
+ response
159
+ end
160
+
161
+ protected
162
+
163
+ def destroy_session ()
164
+ request.session.send(request.session.respond_to?(:destroy) ? :destroy : :clear)
165
+ end
166
+
167
+ def found_redirect_response (url, message = 'Redirecting to URL')
168
+ [ 302,
169
+ { 'location' => url,
170
+ 'content-type' => 'text/plain' },
171
+ [ "#{message}: #{url}" ] ]
172
+ end
173
+
174
+ def internal_server_error_response (content = 'Internal server error')
175
+ [ 500,
176
+ { 'content-type' => 'text/html',
177
+ 'content-length' => content.length },
178
+ [ content ] ]
179
+ end
180
+
181
+ def log (env, message, level = :info)
182
+ env['rack.logger'] ||= Rails.logger if defined?(Rails.logger)
183
+ message = "rack_entra_id_auth: #{message}"
184
+
185
+ if env['rack.logger']
186
+ env['rack.logger'].send(level, message)
187
+ else
188
+ env['rack.errors'].write(message.concat("\n"))
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,123 @@
1
+ require 'uri'
2
+
3
+ require 'rack_entra_id_auth/entra_id_request'
4
+
5
+ module RackEntraIdAuth
6
+ class MockMiddleware
7
+ def initialize (app)
8
+ @app = app
9
+ end
10
+
11
+ def call (env)
12
+ request = Rack::Request.new(env)
13
+ entra_id_request = EntraIdRequest.new(request)
14
+
15
+ # mock a login page
16
+ if entra_id_request.login? and request.request_method.eql?('GET')
17
+ log(env, 'Rendering mock login page…')
18
+
19
+ return [ 200,
20
+ { 'Content-Type' => 'text/html' },
21
+ [ login_page(request.url) ] ]
22
+ end
23
+
24
+ # mock a login request
25
+ if entra_id_request.login? and request.request_method.eql?('POST')
26
+ log(env, 'Initializing session and redirecting to relay state URL…')
27
+
28
+ attributes = RackEntraIdAuth.config.mock_attributes[request.params['username']] || {}
29
+ redirect_url = request.params['relay_state'] ||
30
+ request.params['RelayState'] ||
31
+ RackEntraIdAuth.config.login_relay_state_url ||
32
+ entra_id_request.base_url
33
+
34
+ request.session[RackEntraIdAuth.config.session_key] = RackEntraIdAuth.config.session_value_proc.call(attributes)
35
+
36
+ return found_redirect_response(
37
+ redirect_url,
38
+ 'Initializing session and redirecting to relay state URL')
39
+ end
40
+
41
+ # mock a logout request
42
+ if entra_id_request.logout?
43
+ log(env, 'Destroying session and redirecting to relay state URL…')
44
+
45
+ redirect_url = request.params['relay_state'] ||
46
+ request.params['RelayState'] ||
47
+ RackEntraIdAuth.config.logout_relay_state_url ||
48
+ entra_id_request.base_url
49
+
50
+ request.session.send(request.session.respond_to?(:destroy) ? :destroy : :clear)
51
+
52
+ return found_redirect_response(
53
+ redirect_url,
54
+ 'Destroying session and redirecting to relay state URL')
55
+ end
56
+
57
+ response = @app.call(env)
58
+
59
+ # Authenticate 401s
60
+ if response[0] == 401
61
+ log(env, 'Intercepted 401 Unauthorized response, redirecting to mock login URL…')
62
+
63
+ login_url = URI::HTTP.build(host: request.host,
64
+ port: request.port,
65
+ path: RackEntraIdAuth.config.login_path,
66
+ query: URI.encode_www_form({ :RelayState => request.url }))
67
+
68
+ return found_redirect_response(
69
+ login_url,
70
+ 'Intercepted 401 Unauthorized response, redirecting to mock login URL')
71
+ end
72
+
73
+ response
74
+ end
75
+
76
+ protected
77
+
78
+ def found_redirect_response (url, message = 'Redirecting to URL')
79
+ [ 302,
80
+ { 'location' => url,
81
+ 'content-type' => 'text/plain' },
82
+ [ "#{message}: #{url}" ] ]
83
+ end
84
+
85
+ def log (env, message, level = :info)
86
+ env['rack.logger'] ||= Rails.logger if defined?(Rails.logger)
87
+ message = "rack_entra_id_auth: #{message}"
88
+
89
+ if env['rack.logger']
90
+ env['rack.logger'].send(level, message)
91
+ else
92
+ env['rack.errors'].write(message.concat("\n"))
93
+ end
94
+ end
95
+
96
+ def login_page (action)
97
+ options = RackEntraIdAuth.config.mock_attributes.keys.map { |key| %Q~<option value="#{key}">#{key}</option>~ } .join
98
+
99
+ <<-EOS
100
+ <!DOCTYPE html>
101
+ <html>
102
+ <head>
103
+ <meta charset="utf-8">
104
+ <title>Mock Middleware Login</title>
105
+ </head>
106
+
107
+ <body>
108
+ <form action="#{action}" method="post">
109
+ <label for="username">Username</label>
110
+
111
+ <select id="username" name="username">
112
+ <option label="No User" value=""></option>
113
+ #{options}
114
+ </select>
115
+
116
+ <input type="submit" value="Login">
117
+ </form>
118
+ </body>
119
+ </html>
120
+ EOS
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,19 @@
1
+ require 'rack'
2
+
3
+ module RackEntraIdAuth
4
+ class Railtie < ::Rails::Railtie
5
+ config.rack_entra_id_auth = RackEntraIdAuth.config
6
+
7
+ initializer 'Add RackEntraIdAuth Middleware' do |app|
8
+ if config.rack_entra_id_auth.mock_server
9
+ require 'rack_entra_id_auth/mock_middleware'
10
+
11
+ app.middleware.use MockMiddleware
12
+ else
13
+ require 'rack_entra_id_auth/middleware'
14
+
15
+ app.middleware.use Middleware
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module RackEntraIdAuth
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,14 @@
1
+ require 'rack_entra_id_auth/configuration'
2
+
3
+ module RackEntraIdAuth
4
+ def self.configure
5
+ yield config
6
+ end
7
+
8
+ def self.config
9
+ @config ||= Configuration.new
10
+ end
11
+ end
12
+
13
+ # done here so the RackEntraIdAuth.config method can be used in the Railtie
14
+ require 'rack_entra_id_auth/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack_entra_id_auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - David Susco
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ruby-saml
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.10'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.16'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.16'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.21'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.21'
97
+ description: Rails aware Rack middleware for Entra ID authentication.
98
+ email:
99
+ - dsusco@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - lib/generators/rack_entra_id_auth/initializer_generator.rb
108
+ - lib/generators/rack_entra_id_auth/routes_generator.rb
109
+ - lib/generators/rack_entra_id_auth/templates/config_initializer.rb
110
+ - lib/rack_entra_id_auth.rb
111
+ - lib/rack_entra_id_auth/configuration.rb
112
+ - lib/rack_entra_id_auth/entra_id_request.rb
113
+ - lib/rack_entra_id_auth/middleware.rb
114
+ - lib/rack_entra_id_auth/mock_middleware.rb
115
+ - lib/rack_entra_id_auth/railtie.rb
116
+ - lib/rack_entra_id_auth/version.rb
117
+ homepage: https://github.com/dsusco/rack_entra_id_auth
118
+ licenses:
119
+ - MIT
120
+ metadata:
121
+ bug_tracker_uri: https://github.com/dsusco/rack_entra_id_auth/issues
122
+ changelog_uri: https://github.com/dsusco/rack_entra_id_auth/releases/tag/v1.0.0
123
+ homepage_uri: https://github.com/dsusco/rack_entra_id_auth
124
+ source_code_uri: https://github.com/dsusco/rack_entra_id_auth
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 3.0.0
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.5.11
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: Rails aware Rack middleware for Entra ID authentication.
144
+ test_files: []