saml_idp_rails 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ee42ec55e4011bf7db613fd7ac82ae6357505f95cf19892f1203a659cb88a2fb
4
+ data.tar.gz: 204c561da47fc51ab53de0bb9b34d6a6d519febbfe638dd05b44a501176fe251
5
+ SHA512:
6
+ metadata.gz: 01c41f507dc9fe3b896382d9009ebafb2096e7b68b0c6d9c85413c167f9a9da7889cc0c9cd4b33b14e7cdc764ebe2dbe31d42352276336af04fbcecb7cc205e7
7
+ data.tar.gz: cfc3728394769efa8cff0370a500db391fa237379a60f12bfe3a82ab29ecd81869d6c5e0e65de2030766d303e4da1ac3e9ce2a2752e18473b25ce73ca1b29ce0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright zogoo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # SamlIdpRails
2
+ A Ruby gem that implements SAML Identity Provider (IdP) functionality for Rails applications.
3
+
4
+ ## Usage
5
+ This gem allows you to add SAML IdP capabilities to your Rails application.
6
+
7
+ ## Installation
8
+
9
+ 1. Add this line to your Rails application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "saml_idp_rails"
13
+ ```
14
+
15
+ And then execute:
16
+ ```bash
17
+ $ bundle
18
+ ```
19
+
20
+ Or install it yourself as:
21
+ ```bash
22
+ $ gem install saml_idp_rails
23
+ ```
24
+
25
+ 2. Generate the necessary migrations for Active Record:
26
+ ```bash
27
+ bin/rails saml_idp_rails:install:migrations
28
+ ```
29
+
30
+ 3. Migrate the Service Provider settings to your database:
31
+ ```bash
32
+ bin/rails db:migrate
33
+ ```
34
+
35
+ 4. Configure your IdP service:
36
+ Create a configuration file in your Rails initializers directory:
37
+
38
+ ```rb
39
+ # config/initializers/saml_idp_config.rb
40
+
41
+ SamlIdpRails.configure do |config|
42
+ # Base URL of your application
43
+ config.base_url = "http://localhost:3000"
44
+
45
+ # URL where users will be redirected to sign in
46
+ config.sign_in_url = "/users/sign_in"
47
+
48
+ # Default URL to redirect after successful authentication
49
+ config.relay_state_url = "/home"
50
+
51
+ # Hook to validate user session
52
+ config.session_validation_hook = ->(session) { true }
53
+
54
+ # Lambda to find SAML Service Provider configuration
55
+ config.saml_config_finder = lambda do |_request|
56
+ SamlIdpRails::SamlSpConfig.find_by(uuid: params.require(:uuid))
57
+ end
58
+
59
+ # Lambda to find and return user information for SAML response
60
+ config.saml_user_finder = lambda do |_request|
61
+ User = Struct.new(:name_id_attribute, :email, keyword_init: true)
62
+ User.new(
63
+ name_id_attribute: "email",
64
+ email: "user@example.com"
65
+ )
66
+ end
67
+ end
68
+ ```
69
+
70
+ 5. Create a Service Provider (SP) configuration:
71
+ After running migrations, you'll find a migration file in your Rails app under:
72
+ `db/migrate/YYYYMMDDHHMMSS_create_saml_idp_rails_saml_sp_configs.saml_idp_rails.rb`
73
+
74
+ Example of creating an SP configuration:
75
+
76
+ ```rb
77
+ SamlIdpRails::SamlSpConfig.create(
78
+ # Basic SP Information
79
+ name: "Test SP Config One", # Unique identifier for the SP configuration
80
+ display_name: "Test SP One", # Human-readable name for the SP
81
+ entity_id: "http://test-sp-one.com", # Entity ID provided by the SP
82
+
83
+ # Certificate and Key Configuration
84
+ signing_certificate: "Your IdP public key", # Base64 encoded IdP public key
85
+ encryption_certificate: nil, # Optional: SP encryption certificate
86
+ private_key: "Your IdP private key", # IdP private key (keep secure)
87
+ pv_key_password: nil, # Optional: Private key password if encrypted
88
+
89
+ # SAML Settings
90
+ sign_assertions: true, # Whether to sign SAML assertions
91
+ sign_authn_request: false, # Whether SP requires signed authentication requests
92
+ certificate: "SP X509 certificate", # SP's public certificate for signature validation
93
+ relay_state: "sample_relay_state", # Post-SSO redirect URL
94
+ name_id_attribute: "email", # Attribute to use as NameID
95
+ raw_metadata: nil, # Optional: Raw SP metadata XML
96
+
97
+ # SAML Format Settings
98
+ name_id_formats: ["email_address"], # Supported NameID formats
99
+
100
+ # Service Endpoints
101
+ assertion_consumer_services: [{
102
+ "binding" => "HTTP-POST",
103
+ "default" => "true",
104
+ "location" => "http://test-sp-one.com/acs"
105
+ }],
106
+ single_logout_services: {
107
+ "HTTP-Redirect" => "http://test-sp-one.com/slo"
108
+ },
109
+
110
+ # Contact Information
111
+ contact_person: {
112
+ "surname" => "Doe",
113
+ "given_name" => "John",
114
+ "email_address" => "john.doe@test-sp-one.com"
115
+ },
116
+
117
+ # SAML Attributes
118
+ saml_attributes: [{
119
+ "name" => "email",
120
+ "getter" => "email",
121
+ "nameFormat" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
122
+ "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
123
+ "friendlyName" => "Email address"
124
+ }],
125
+
126
+ # Public Identifier
127
+ uuid: "8ad17d2a-f870-4796-8212-7487411b8578" # Unique identifier for SP configuration
128
+ )
129
+ ```
130
+
131
+ Once you successfully create the correct `SamlSpConfig`.
132
+ Then you can test it in your development environment to accessing following url
133
+
134
+ 1. Metadata endpoint
135
+ ```bash
136
+ curl http://localhost:3000/saml_idp/8ad17d2a-f870-4796-8212-7487411b8578/metadata
137
+ ```
138
+
139
+ 2. IdP initiated SAML SSO
140
+ You will see SAML response posted `http://test-sp-one.com/acs` in the Network tab of Browser dev tool.
141
+
142
+ ```url
143
+ http://localhost:3000/saml_idp/8ad17d2a-f870-4796-8212-7487411b8578/sso
144
+ ```
145
+
146
+ ## Contributing
147
+ To contribute, fork the repository and create a pull request with your changes.
148
+
149
+ ## License
150
+ This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,4 @@
1
+ module SamlIdpRails
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,143 @@
1
+ require_relative "../../../lib/saml_idp_rails/saml_config"
2
+
3
+ module SamlIdpRails
4
+ class SamlIdpController < ApplicationController
5
+ layout false
6
+ include SamlIdp::Controller
7
+
8
+ before_action :store_authn_request, only: %i[sso_request], unless: :user_signed_in?
9
+ before_action :validate_session
10
+ before_action :load_config, only: %i[sso_request slo_request initiate_slo metadata]
11
+ before_action :validate_saml_request, only: %i[sso_request slo_request], if: :sp_initiated_request?
12
+
13
+ def sso_request
14
+ saml_response
15
+ render :sso_response
16
+ end
17
+
18
+ def slo_request
19
+ return redirect_to SamlIdpRails.config.relay_state_url, allow_other_host: true unless sp_initiated_request?
20
+
21
+ saml_slo_response = encode_logout_response(
22
+ current_saml_user,
23
+ @saml_config.append_request_config(saml_request).merge!(
24
+ public_cert: current_sp_config.certificate,
25
+ private_key: current_sp_config.private_key,
26
+ pv_key_password: current_sp_config.pv_key_password
27
+ )
28
+ )
29
+
30
+ # TODO: move this part to gem
31
+ # If SLO request doesn't contain the SLO endpoint then use SP config default SLO url
32
+ @sp_slo_endpoint = saml_request&.logout_url || current_sp_config.single_logout_services&.values&.first
33
+ @sp_slo_binding = current_sp_config.single_logout_services&.keys&.first == "HTTP-Redirect" ? :redirect : :post
34
+ saml_slo_response = Zlib::Deflate.deflate(saml_slo_response, 9)[2..-5] if @sp_slo_binding == :redirect
35
+ @saml_slo_response = Base64.strict_encode64(saml_slo_response)
36
+ @sp_slo_url = generate_url(host: @sp_slo_endpoint, SAMLResponse: @saml_slo_response, RelayState: SamlIdpRails.config.relay_state_url)
37
+ render :slo_response
38
+ end
39
+
40
+ def initiate_slo
41
+ # TODO: move it out to "saml_idp" gem
42
+ slo_endpoint = current_sp_config.single_logout_services
43
+ binding = slo_endpoint&.keys&.first == "HTTP-Redirect" ? :get : :post
44
+ slo_location = slo_endpoint&.values&.first
45
+
46
+ logout_request = SamlIdp::LogoutRequestBuilder.new(
47
+ response_id: SecureRandom.uuid,
48
+ issuer_uri: SamlIdpRails.config.base_url,
49
+ saml_slo_url: slo_location,
50
+ name_id: @saml_config.name_id_value,
51
+ algorithm: OpenSSL::Digest::SHA256, # TODO: Update this to use the SP's digest method
52
+ public_cert: current_sp_config.certificate,
53
+ private_key: current_sp_config.private_key,
54
+ pv_key_password: current_sp_config.pv_key_password
55
+ ).signed
56
+
57
+ @slo_request_params = {
58
+ name: current_sp_config.name,
59
+ location: slo_location,
60
+ params: {
61
+ SAMLRequest: binding == :get ? Base64.encode64(logout_request) : logout_request,
62
+ RelayState: SamlIdpRails.config.relay_state_url
63
+ },
64
+ method: binding
65
+ }
66
+ render :slo_request
67
+ end
68
+
69
+ def metadata
70
+ render xml: @saml_config.idp_metadata
71
+ end
72
+
73
+ def attribute
74
+ # TODO: Remove this endpoint from the saml_idp gem
75
+ render json: @saml_config.saml_attributes
76
+ end
77
+
78
+ private
79
+
80
+ def saml_response
81
+ @saml_response = encode_authn_response(
82
+ current_saml_user,
83
+ @saml_config.append_request_config(saml_request).merge!(
84
+ public_cert: current_sp_config.certificate,
85
+ private_key: current_sp_config.private_key,
86
+ pv_key_password: current_sp_config.pv_key_password
87
+ )
88
+ )
89
+ end
90
+
91
+ def saml_request
92
+ # GEM will decode SP initiated request which is mean @saml_request set by gem
93
+ sp_initiated_request? ? @saml_request : @saml_config.saml_request
94
+ end
95
+
96
+ def sp_initiated_request?
97
+ params[:SAMLRequest].present?
98
+ end
99
+
100
+ def current_sp_config
101
+ @current_sp_config ||= begin
102
+ instance_exec(request, &SamlIdpRails.config.saml_config_finder)
103
+ end
104
+ end
105
+
106
+ def current_saml_user
107
+ @current_saml_user ||= begin
108
+ instance_exec(request, &SamlIdpRails.config.saml_user_finder)
109
+ end
110
+ end
111
+
112
+ def store_authn_request
113
+ saml_authn_request = if request.post?
114
+ saml_post_req = { SAMLRequest: params[:SAMLRequest], RelayState: params[:RelayState] }
115
+ "#{request.fullpath}?#{saml_post_req.to_query}"
116
+ else
117
+ request.fullpath
118
+ end
119
+
120
+ session[:sp_config_id] = current_sp_config.id
121
+ session[:saml_auth_request] = saml_authn_request
122
+
123
+ redirect_to SamlIdpRails.config.sign_in_url, allow_other_host: true
124
+ end
125
+
126
+ def user_signed_in?
127
+ current_saml_user.present?
128
+ end
129
+
130
+ def load_config
131
+ @saml_config = SamlIdpRails::SamlConfig.new(current_sp_config, current_saml_user)
132
+ @saml_config.configure_saml_idp
133
+ end
134
+
135
+ def validate_session
136
+ SamlIdpRails.config.session_validation_hook.call(session) if SamlIdpRails.config.session_validation_hook.present?
137
+ end
138
+
139
+ def generate_url(host:, **params)
140
+ "#{host}?#{params.to_query}"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,4 @@
1
+ module SamlIdpRails
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module SamlIdpRails
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module SamlIdpRails
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module SamlIdpRails
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,50 @@
1
+ require "saml_idp"
2
+
3
+ module SamlIdpRails
4
+ class SamlSpConfig < ApplicationRecord
5
+ before_create :generate_uuid
6
+
7
+ if connection.adapter_name.downcase.starts_with?("sqlite")
8
+ serialize :name_id_formats, coder: JSON
9
+ serialize :assertion_consumer_services, coder: JSON
10
+ serialize :single_logout_services, type: Hash, coder: JSON
11
+ serialize :contact_person, type: Hash, coder: JSON
12
+ serialize :saml_attributes, coder: JSON
13
+ end
14
+
15
+ def parsed_metadata
16
+ metadata_attr = ::SamlIdp::IncomingMetadata.new(raw_metadata).to_h
17
+ # TODO: Move this logic to GEM
18
+ metadata_attr[:name_id_formats] = metadata_attr[:name_id_formats].to_a
19
+ if raw_metadata.present?
20
+ assign_attributes(metadata_attr.except(:unspecified_certificate))
21
+ # When SP metadata contains a <KeyDescriptor> that is not specified as signing or encryption,
22
+ # this method assigns the certificate to signing only.
23
+ self.signing_certificate = metadata_attr[:unspecified_certificate] if metadata_attr[:unspecified_certificate].present?
24
+ encoded_certificates
25
+ end
26
+ self
27
+ end
28
+
29
+ # SP metadata removes header and footer of the certificate
30
+ # This method adds them back
31
+ def encoded_certificates
32
+ self.signing_certificate = format_with_pem(signing_certificate) if signing_certificate.present? && !pem_formatted?(signing_certificate)
33
+ self.encryption_certificate = format_with_pem(encryption_certificate) if encryption_certificate.present? && !pem_formatted?(encryption_certificate)
34
+ end
35
+
36
+ private
37
+
38
+ def generate_uuid
39
+ self.uuid = SecureRandom.uuid
40
+ end
41
+
42
+ def pem_formatted?(cert)
43
+ cert.scan(/(-----BEGIN CERTIFICATE-----)(.+?)(-----END CERTIFICATE-----)/m).any?
44
+ end
45
+
46
+ def format_with_pem(cert)
47
+ "-----BEGIN CERTIFICATE-----\n#{cert.strip}\n-----END CERTIFICATE-----"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>SamlIdpRails</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "saml_idp_rails/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Logging out from <%= @slo_request_params[:name] %></title>
5
+ </head>
6
+ <body>
7
+ <p>Logging out from <%= @slo_request_params[:name] %>...</p>
8
+ <form id="slo_form" action="<%= @slo_request_params[:location] %>" method="<%= @slo_request_params[:method] %>">
9
+ <% @slo_request_params[:params].each do |key, value| %>
10
+ <input type="hidden" name="<%= key %>" value="<%= value %>">
11
+ <% end %>
12
+ </form>
13
+ <script type="text/javascript">
14
+ // Automatically submit the form
15
+ document.getElementById('slo_form').submit();
16
+ </script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
6
+ <% if @sp_slo_binding == :redirect %>
7
+ <meta http-equiv="refresh" content="0;url=<%=@sp_slo_url%>">
8
+ <title>Redirecting...</title>
9
+ <% end %>
10
+ </head>
11
+ <% if @sp_slo_binding == :redirect %>
12
+ <p>This page has moved. If you are not redirected automatically, <a href="<%=@sp_slo_url%>">click here</a>.</p>
13
+ <% else %>
14
+ <body onload="document.forms[0].submit();" style="visibility:hidden;">
15
+ <%= form_tag(@sp_slo_endpoint) do %>
16
+ <%= hidden_field_tag("SAMLResponse", @saml_slo_response) %>
17
+ <%= hidden_field_tag("RelayState", SamlIdpRails.config.relay_state_url) %>
18
+ <%= submit_tag "Submit" %>
19
+ <% end %>
20
+ </body>
21
+ <% end %>
22
+ </html>
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
6
+ </head>
7
+ <body onload="document.forms[0].submit();" style="visibility:hidden;">
8
+ <%= form_tag(saml_acs_url) do %>
9
+ <%= hidden_field_tag("SAMLResponse", @saml_response) %>
10
+ <%= hidden_field_tag("RelayState", params[:RelayState]) %>
11
+ <%= submit_tag "Submit" %>
12
+ <% end %>
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ SamlIdpRails::Engine.routes.draw do
2
+ get ":uuid/metadata", to: "saml_idp#metadata", as: "metadata"
3
+ post ":uuid/sso", to: "saml_idp#sso_request", as: "sso_post"
4
+ get ":uuid/sso", to: "saml_idp#sso_request", as: "sso_redirect"
5
+ post ":uuid/logout", to: "saml_idp#slo_request", as: "slo_post"
6
+ get ":uuid/logout", to: "saml_idp#slo_request", as: "slo_redirect"
7
+ post ":uuid/slo_request", to: "saml_idp#initiate_slo", as: "initiate_slo"
8
+ get ":uuid/attribute", to: "saml_idp#attribute", as: "attribute"
9
+ end
@@ -0,0 +1,62 @@
1
+ class CreateSamlIdpRailsSamlSpConfigs < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :saml_idp_rails_saml_sp_configs do |t|
4
+ # For identification
5
+ t.string :name
6
+ t.string :display_name
7
+ # SP attributes
8
+ t.string :entity_id
9
+ t.string :signing_certificate
10
+ t.string :encryption_certificate
11
+ t.boolean :sign_assertions
12
+ t.boolean :sign_authn_request
13
+ # IdP attributes
14
+ t.string :certificate
15
+ t.string :private_key
16
+ t.string :pv_key_password
17
+ t.string :relay_state
18
+ t.string :name_id_attribute
19
+ t.text :raw_metadata
20
+
21
+ if connection.adapter_name.downcase.starts_with?('postgresql')
22
+ # SP attributes
23
+ t.text :name_id_formats, array: true, default: []
24
+ t.jsonb :assertion_consumer_services, default: []
25
+ t.jsonb :single_logout_services, default: {}
26
+ # IdP attributes
27
+ t.jsonb :contact_person, default: {}
28
+ t.jsonb :saml_attributes, default: {}
29
+ elsif connection.adapter_name.downcase.starts_with?('mysql')
30
+ # SP attributes
31
+ t.text :name_id_formats, array: true, default: []
32
+ t.json :assertion_consumer_services, default: []
33
+ t.json :single_logout_services, default: {}
34
+ # IdP attributes
35
+ t.json :contact_person, default: {}
36
+ t.json :saml_attributes, default: {}
37
+ else
38
+ # SP attributes
39
+ t.text :name_id_formats, default: '[]'
40
+ t.text :assertion_consumer_services, default: '[]'
41
+ t.text :single_logout_services, default: '{}'
42
+ # IdP attributes
43
+ t.text :contact_person, default: '{}'
44
+ t.text :saml_attributes, default: '{}'
45
+ end
46
+
47
+ # Public UUID for SP
48
+ if connection.adapter_name.downcase.starts_with?('mysql')
49
+ t.string :uuid, null: false, default: -> { "UUID()" }
50
+ elsif connection.adapter_name.downcase.starts_with?('postgresql')
51
+ t.uuid :uuid, null: false, default: -> { "gen_random_uuid()" }
52
+ else
53
+ t.string :uuid, null: false
54
+ end
55
+
56
+ t.timestamps
57
+ end
58
+
59
+ add_index :saml_idp_rails_saml_sp_configs, :name, unique: true
60
+ add_index :saml_idp_rails_saml_sp_configs, :entity_id, unique: true
61
+ end
62
+ end
@@ -0,0 +1,27 @@
1
+ require "saml_idp"
2
+
3
+ module SamlIdpRails
4
+ class Config
5
+ ATTRIBUTES = %i[
6
+ base_url
7
+ sign_in_url
8
+ relay_state_url
9
+ session_validation_hook
10
+ saml_config_finder
11
+ saml_user_finder
12
+ ].freeze
13
+
14
+ attr_accessor *ATTRIBUTES
15
+
16
+ def configure(&block)
17
+ yield self if block_given?
18
+ self
19
+ end
20
+
21
+ def validate!
22
+ ATTRIBUTES.each do |attribute|
23
+ raise("SamlIdpRails: #{attribute} is not set") if self.public_send(attribute).nil?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ module SamlIdpRails
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace SamlIdpRails
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ module SamlIdpRails
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,240 @@
1
+ require "saml_idp"
2
+
3
+ module SamlIdpRails
4
+ class SamlConfig
5
+ include SamlIdpRails::Engine.routes.url_helpers
6
+
7
+ attr_accessor :config
8
+
9
+ def initialize(sp_config, saml_user)
10
+ @config = {}
11
+ @config[:base_url] = SamlIdpRails.config.base_url
12
+ @config[:saml_config] = sp_config
13
+ @config[:saml_user] = saml_user
14
+ end
15
+
16
+ def configure_saml_idp
17
+ ::SamlIdp.configure do |config|
18
+ config.x509_certificate = saml_config.certificate
19
+ config.secret_key = saml_config.private_key
20
+ config.password = saml_config.pv_key_password
21
+ config.algorithm = :sha256
22
+ config.organization_name = base_url
23
+ config.organization_url = base_url
24
+ # URL configuration
25
+ config.base_saml_location = base_url # TODO: Read from gem configuration
26
+ config.single_logout_service_post_location = slo_post_endpoint
27
+ config.single_logout_service_redirect_location = slo_redirect_endpoint
28
+ config.attribute_service_location = attribute_endpoint
29
+ config.single_service_post_location = sso_post_endpoint
30
+ config.single_service_redirect_location = sso_redirect_endpoint
31
+ # Name ID format
32
+ config.name_id.formats = name_id_format
33
+ config.attributes = saml_attributes_as_hash
34
+ config.service_provider.metadata_persister = metadata_persister
35
+ config.service_provider.persisted_metadata_getter = persisted_matadata
36
+ config.service_provider.finder = service_providers
37
+ config.logger = Rails.logger
38
+ end
39
+ end
40
+
41
+ def append_request_config(saml_request)
42
+ config = {}
43
+ if saml_config.encryption_certificate.present?
44
+ config = {
45
+ encryption: {
46
+ cert: saml_config.encryption_certificate,
47
+ block_encryption: "aes256-cbc",
48
+ key_transport: "rsa-oaep-mgf1p"
49
+ }
50
+ }
51
+ end
52
+
53
+ config[:signed_assertion] = saml_config.sign_assertions
54
+ config[:signed_message] = true
55
+
56
+ # SP initiated SAML
57
+ if saml_request.present? && !saml_request.try(:idp_initiated?)
58
+ config[:acs_url] = saml_request.request["AssertionConsumerServiceURL"] if saml_request.authn_request?
59
+ return config
60
+ end
61
+
62
+ config.merge!(audience_uri: saml_config.entity_id)
63
+ end
64
+
65
+ def idp_metadata
66
+ SamlIdp.metadata.signed
67
+ end
68
+
69
+ def saml_request
70
+ @saml_request ||= Struct.new(
71
+ :request_id,
72
+ :issue_url,
73
+ :acs_url
74
+ ) do
75
+ def authn_request?
76
+ true
77
+ end
78
+
79
+ def idp_initiated?
80
+ true
81
+ end
82
+
83
+ def issuer
84
+ url = URI(issue_url)
85
+ url.query = nil
86
+ url.to_s
87
+ end
88
+ end.new(nil, base_url, default_acs_config[:location])
89
+ end
90
+
91
+ def name_id_value(attribute_name = nil)
92
+ attr = attribute_name.presence || saml_user.name_id_attribute
93
+ val = saml_user.public_send(attr) if saml_user.respond_to?(attr)
94
+ raise("SamlIdpRails: Name ID attribute #{attr} is not set") if val.blank?
95
+ val
96
+ end
97
+
98
+ private
99
+
100
+ def service_providers
101
+ lambda { |_issuer_or_entity_id|
102
+ {
103
+ response_hosts: saml_config.assertion_consumer_services.map do |acs|
104
+ url = acs["location"] || acs[:location]
105
+ URI(url).host
106
+ end,
107
+ acs_url: default_acs_config[:location],
108
+ cert: (saml_config.signing_certificate.present? ? saml_config.signing_certificate : nil),
109
+ fingerprint: (saml_config.signing_certificate.present? ? SamlIdp::Fingerprint.certificate_digest(saml_config.signing_certificate, :sha256) : nil),
110
+ assertion_consumer_logout_service_url: saml_config.single_logout_services.values.first,
111
+ sign_authn_request: saml_config.sign_authn_request
112
+ }
113
+ }
114
+ end
115
+
116
+ def persisted_matadata
117
+ lambda { |_identifier, _|
118
+ # TODO: eliminate raw metadata usage
119
+ SamlIdp::IncomingMetadata.new(saml_config.raw_metadata)
120
+ }
121
+ end
122
+
123
+ # We don't need support it because, scary XML can be there
124
+ # TODO: Remove this method from GEM
125
+ def metadata_persister
126
+ lambda { |_identifier, _service_provider|
127
+ }
128
+ end
129
+
130
+ def default_name_idp_format
131
+ {
132
+ "1.1" => {
133
+ email_address: lambda { |_principal|
134
+ name_id_value
135
+ }
136
+ }
137
+ }
138
+ end
139
+
140
+ def name_id_format
141
+ first_name_format = saml_config.name_id_formats.first.to_s
142
+ first_name_format = "unspecified" if first_name_format.blank?
143
+ {
144
+ name_id_format_version(first_name_format).to_s => {
145
+ # TODO: Remove lambdas from GEM
146
+ first_name_format => lambda { |_principal|
147
+ name_id_value
148
+ }
149
+ }
150
+ }
151
+ end
152
+
153
+ def name_id_format_version(parsed_format)
154
+ return "1.1" if %w[email_address unspecified].include?(parsed_format.underscore)
155
+
156
+ "2.0"
157
+ end
158
+
159
+ def default_acs_config
160
+ (saml_config.assertion_consumer_services.first || {}).with_indifferent_access
161
+ end
162
+
163
+ def saml_attributes_as_hash
164
+ config_attribute = {}
165
+ saml_config.saml_attributes.each do |attribute|
166
+ # TODO: Resolve this issue on GEM side
167
+ attribute["name_format"] = attribute["nameFormat"] if attribute["nameFormat"].present?
168
+
169
+ config_attribute[attribute["friendlyName"]] = attribute.except("friendlyName")
170
+ # TODO: Remove lambdas from GEM
171
+ config_attribute[attribute["friendlyName"]]["getter"] = lambda { |_principal|
172
+ saml_attribute_getters(attribute["getter"])
173
+ }
174
+ end
175
+ config_attribute
176
+ end
177
+
178
+ def saml_attribute_getters(config_value)
179
+ attribute_value = user_attribute(config_value)
180
+ attribute_type = attribute_value.class.to_s
181
+ # Fixed string value
182
+ return config_value.to_s if attribute_value.blank?
183
+
184
+ case attribute_type
185
+ when "Array"
186
+ attribute_value.map(&:to_s)
187
+ when "Hash"
188
+ attribute_value.to_json
189
+ when "Integer"
190
+ attribute_value.to_s
191
+ when "String"
192
+ attribute_value
193
+ else
194
+ ""
195
+ end
196
+ end
197
+
198
+ def user_attribute(key)
199
+ saml_user.respond_to?(key) ? saml_user.public_send(key) : saml_user[key]
200
+ end
201
+
202
+ def metadata_endpoint
203
+ @config[:metadata_url] || metadata_url(uuid: saml_config.uuid, host: base_url)
204
+ end
205
+
206
+ def slo_post_endpoint
207
+ @config[:slo_post_url] || slo_post_url(uuid: saml_config.uuid, host: base_url)
208
+ end
209
+
210
+ # Form converts REDIRECT to POST
211
+ def slo_redirect_endpoint
212
+ @config[:metadata_url] || slo_redirect_url(uuid: saml_config.uuid, host: base_url)
213
+ end
214
+
215
+ def attribute_endpoint
216
+ @config[:attribute_url] || attribute_url(uuid: saml_config.uuid, host: base_url)
217
+ end
218
+
219
+ def sso_post_endpoint
220
+ @config[:sso_post_url] || sso_post_url(uuid: saml_config.uuid, host: base_url)
221
+ end
222
+
223
+ # Form converts REDIRECT to POST
224
+ def sso_redirect_endpoint
225
+ @config[:sso_redirect_url] || sso_redirect_url(uuid: saml_config.uuid, host: base_url)
226
+ end
227
+
228
+ def saml_user
229
+ @config[:saml_user] || raise("SamlIdpRails: saml_user is not set")
230
+ end
231
+
232
+ def saml_config
233
+ @config[:saml_config] || raise("SamlIdpRails: saml_config is not set")
234
+ end
235
+
236
+ def base_url
237
+ @config[:base_url] || raise("SamlIdpRails: base_url is not set")
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,3 @@
1
+ module SamlIdpRails
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,16 @@
1
+ require "saml_idp_rails/version"
2
+ require "saml_idp_rails/engine"
3
+ require "saml_idp_rails/config"
4
+
5
+ module SamlIdpRails
6
+ class << self
7
+ def configure(&block)
8
+ @config = Config.new.configure(&block)
9
+ @config.validate!
10
+ end
11
+
12
+ def config
13
+ @config
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :saml_idp_rails do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: saml_idp_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - zogoo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-23 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.0.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.0.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: saml_idp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: debug
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: ruby-saml
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: SamlIdpRails is open source Idp controller for Rails.
83
+ email:
84
+ - ch.zogoo@gmail.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - MIT-LICENSE
90
+ - README.md
91
+ - Rakefile
92
+ - app/assets/stylesheets/saml_idp_rails/application.css
93
+ - app/controllers/saml_idp_rails/application_controller.rb
94
+ - app/controllers/saml_idp_rails/saml_idp_controller.rb
95
+ - app/helpers/saml_idp_rails/application_helper.rb
96
+ - app/jobs/saml_idp_rails/application_job.rb
97
+ - app/mailers/saml_idp_rails/application_mailer.rb
98
+ - app/models/saml_idp_rails/application_record.rb
99
+ - app/models/saml_idp_rails/saml_sp_config.rb
100
+ - app/views/layouts/saml_idp_rails/application.html.erb
101
+ - app/views/saml_idp_rails/saml_idp/slo_request.html.erb
102
+ - app/views/saml_idp_rails/saml_idp/slo_response.html.erb
103
+ - app/views/saml_idp_rails/saml_idp/sso_response.html.erb
104
+ - config/routes.rb
105
+ - db/migrate/20250120165545_create_saml_idp_rails_saml_sp_configs.rb
106
+ - lib/saml_idp_rails.rb
107
+ - lib/saml_idp_rails/config.rb
108
+ - lib/saml_idp_rails/engine.rb
109
+ - lib/saml_idp_rails/railtie.rb
110
+ - lib/saml_idp_rails/saml_config.rb
111
+ - lib/saml_idp_rails/version.rb
112
+ - lib/tasks/saml_idp_rails_tasks.rake
113
+ homepage: https://github.com/zogoo/saml_idp_rails
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/zogoo/saml_idp_rails
118
+ source_code_uri: https://github.com/zogoo/saml_idp_rails
119
+ changelog_uri: https://github.com/Zogoo/saml_idp_rails/blob/master/CHANGELOG.md
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 3.4.1
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.6.3
135
+ specification_version: 4
136
+ summary: Idp controller for Rails
137
+ test_files: []