fake_idp 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b50b9e2c4cd4e172edc611187bd17f3dac879078abe55d58eb8e6ecab18c9dba
4
+ data.tar.gz: 2339286b95f50fa05dc64cb3dcb2c49b2ee95927c79562a3b8484b95eb1a7ee1
5
+ SHA512:
6
+ metadata.gz: c3956511324619f761439951aa85bd40e0e107bf1b72047a1b9f03b7aa651c3040a7aa277e384e4349cd4908e7b7445b08bdf1e134d7704f78ba78a19d487714
7
+ data.tar.gz: 2b79b09ada2b3fca8c27e4c5dd1f3a35a303ff40bc30d37e740242a08f154282a1d1f8877daa74251035c456eb9a9b7ad5f4af03b5cc9d6014c5954893bacb41
@@ -0,0 +1,7 @@
1
+ SSO_UID=123456
2
+ CALLBACK_URL="http://localhost.dev:3000/auth/saml/devidp/callback"
3
+ USERNAME=bobthessouser
4
+ FIRST_NAME=Bob
5
+ LAST_NAME=TheSsoUser
6
+ EMAIL=bobthessouser@example.com
7
+ NAME_ID=bobthessouser@example.com
@@ -0,0 +1,2 @@
1
+ .idea
2
+ .env
@@ -0,0 +1 @@
1
+ 2.6.5
@@ -0,0 +1,19 @@
1
+ language: ruby
2
+ cache: bundler
3
+ rvm:
4
+ - 2.7
5
+ - 2.6
6
+ - 2.5
7
+ - 2.4
8
+ - truffleruby
9
+ - jruby
10
+ sudo: false
11
+ gemfile: Gemfile
12
+ script: bundle exec rspec spec
13
+ before_install:
14
+ - gem update --system 3.1.2 --no-document
15
+ - gem install bundler -v 2.1.2 --no-document
16
+ jobs:
17
+ allow_failures:
18
+ - rvm: truffleruby
19
+ - rvm: jruby
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source 'https://rubygems.org'
2
+
3
+ git_source(:github) do |repo_name|
4
+ repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
5
+ "https://github.com/#{repo_name}.git"
6
+ end
7
+
8
+ # overrides gem source in gemspec
9
+ # needed to enable attributes statement in SAMLRequest
10
+ gem 'ruby-saml-idp', github: 'lawrencepit/ruby-saml-idp'
11
+
12
+ gemspec
@@ -0,0 +1,102 @@
1
+ GIT
2
+ remote: https://github.com/lawrencepit/ruby-saml-idp.git
3
+ revision: ae53d76af08711affaeb182e7a877676ab554818
4
+ specs:
5
+ ruby-saml-idp (0.3.2)
6
+ uuid
7
+
8
+ PATH
9
+ remote: .
10
+ specs:
11
+ fake_idp (1.0.0)
12
+ activesupport (~> 5.2.4.3)
13
+ builder (>= 3.2.2)
14
+ nokogiri (>= 1.10.5)
15
+ ruby-saml (~> 1.11.0)
16
+ ruby-saml-idp
17
+ sinatra (~> 2.0.0)
18
+ xmlenc (>= 0.7.1)
19
+
20
+ GEM
21
+ remote: https://rubygems.org/
22
+ specs:
23
+ activemodel (5.2.4.3)
24
+ activesupport (= 5.2.4.3)
25
+ activesupport (5.2.4.3)
26
+ concurrent-ruby (~> 1.0, >= 1.0.2)
27
+ i18n (>= 0.7, < 2)
28
+ minitest (~> 5.1)
29
+ tzinfo (~> 1.1)
30
+ builder (3.2.4)
31
+ coderay (1.1.2)
32
+ concurrent-ruby (1.1.7)
33
+ diff-lcs (1.4.2)
34
+ dotenv (1.0.2)
35
+ i18n (1.8.5)
36
+ concurrent-ruby (~> 1.0)
37
+ macaddr (1.7.1)
38
+ systemu (~> 2.6.2)
39
+ method_source (0.9.2)
40
+ mini_portile2 (2.4.0)
41
+ minitest (5.14.1)
42
+ mustermann (1.1.1)
43
+ ruby2_keywords (~> 0.0.1)
44
+ nokogiri (1.10.10)
45
+ mini_portile2 (~> 2.4.0)
46
+ pry (0.12.2)
47
+ coderay (~> 1.1.0)
48
+ method_source (~> 0.9.0)
49
+ rack (2.2.3)
50
+ rack-protection (2.0.8.1)
51
+ rack
52
+ rake (13.0.1)
53
+ rspec (3.9.0)
54
+ rspec-core (~> 3.9.0)
55
+ rspec-expectations (~> 3.9.0)
56
+ rspec-mocks (~> 3.9.0)
57
+ rspec-core (3.9.2)
58
+ rspec-support (~> 3.9.3)
59
+ rspec-expectations (3.9.2)
60
+ diff-lcs (>= 1.2.0, < 2.0)
61
+ rspec-support (~> 3.9.0)
62
+ rspec-mocks (3.9.1)
63
+ diff-lcs (>= 1.2.0, < 2.0)
64
+ rspec-support (~> 3.9.0)
65
+ rspec-support (3.9.3)
66
+ ruby-saml (1.11.0)
67
+ nokogiri (>= 1.5.10)
68
+ ruby2_keywords (0.0.2)
69
+ sinatra (2.0.8.1)
70
+ mustermann (~> 1.0)
71
+ rack (~> 2.0)
72
+ rack-protection (= 2.0.8.1)
73
+ tilt (~> 2.0)
74
+ systemu (2.6.5)
75
+ thread_safe (0.3.6)
76
+ tilt (2.0.10)
77
+ tzinfo (1.2.7)
78
+ thread_safe (~> 0.1)
79
+ uuid (2.3.8)
80
+ macaddr (~> 1.0)
81
+ xmlenc (0.7.1)
82
+ activemodel (>= 3.0.0)
83
+ activesupport (>= 3.0.0)
84
+ nokogiri (>= 1.6.0, < 2.0.0)
85
+ xmlmapper (>= 0.7.3)
86
+ xmlmapper (0.7.3)
87
+ nokogiri (~> 1.5)
88
+
89
+ PLATFORMS
90
+ ruby
91
+
92
+ DEPENDENCIES
93
+ bundler (~> 2)
94
+ dotenv (~> 1.0)
95
+ fake_idp!
96
+ pry (~> 0.12.2)
97
+ rake (~> 13.0)
98
+ rspec (~> 3.9.0)
99
+ ruby-saml-idp!
100
+
101
+ BUNDLED WITH
102
+ 2.1.4
@@ -0,0 +1,128 @@
1
+ # Fake IdP
2
+
3
+ [![Build Status](https://travis-ci.com/healthify/fake_idp.svg?branch=master)](https://travis-ci.com/healthify/fake_idp)
4
+
5
+ ## About
6
+
7
+ This is an open source Ruby gem intended for developers needing to spin up a fake Identity Provider (IdP) for testing SAML authentication flows. It's made by the [Healthify](http://healthify.us) team. It's _not_ for setting up an IdP within Healthify—to do that, you'll need to reach out to integrations@healthify.us.
8
+
9
+ ## Installation
10
+
11
+ Clone the repo and `cd` into the project directory.
12
+
13
+ ```sh
14
+ bin/setup
15
+ ```
16
+
17
+ ## Running in Development
18
+
19
+ To run locally, you first need to set up the following environment variables:
20
+
21
+ |Variables|Description|
22
+ |---|---|
23
+ |CALLBACK_URL|The URL of the Healthify app to POST to for SAML authentication - required|
24
+ |NAME_ID|Name_id of the user you want to log in as - may be nil/blank|
25
+ |SSO_UID|Unique id of the user you want to log in as - may be nil/blank|
26
+ |USERNAME|Username of the user you want to log in as - may be nil/blank|
27
+ |ENCRYPTION_ENABLED| Set to 'true' to indicate that the generated assertion should be encrypted. Defaults to false|
28
+
29
+ The `.env.example` file has examples of what these env variables could look like.
30
+ You can copy that over to your own `.env` file to set these environment variables:
31
+
32
+ cp .env.example .env
33
+
34
+ Next, to start the server, you can run:
35
+
36
+ ```sh
37
+ bin/server
38
+ ```
39
+
40
+ Then navigate to `http://localhost:9292/saml/auth` to begin making your SAML requests.
41
+
42
+ ## Running in Test
43
+
44
+ ### Gemfile
45
+
46
+ If you are using this gem to provide a Fake IDP server in a test suite, add the gem to the Gemfile:
47
+
48
+ ```ruby
49
+ gem 'fake_idp', github: 'healthify/fake_idp'
50
+ ```
51
+
52
+ ### Configuration
53
+
54
+ You can set the relevant variables in a configuration block if they aren't provided as environment variables. For example:
55
+
56
+ ```ruby
57
+ FakeIdp.configure do |config|
58
+ config.callback_url = 'http://localhost.dev:3000/auth/saml/devidp/callback'
59
+ config.sso_uid = '12345'
60
+ config.name_id = 'user@example.com'
61
+ config.username = nil
62
+ config.certificate = "YOUR CERT HERE"
63
+ config.idp_certificate = "YOUR IDP CERT HERE"
64
+ config.idp_secret_key = "YOUR IDP SECRET KEY HERE"
65
+ config.algorithm = :sha512
66
+ config.additional_attributes = { custom_saml_attr: "DELIVERED_IN_ASSERTION" }
67
+ end
68
+ ```
69
+
70
+ #### Resetting Configuration
71
+
72
+ If you ever want to reset your FakeIdp configuration (e.g. between test specs), you can use the following:
73
+
74
+ ```ruby
75
+ FakeIdp.reset!
76
+ ```
77
+
78
+ ### Use
79
+
80
+ You can use Capybara Discoball to spin `FakeIdp::Application` up in a test:
81
+
82
+ ```ruby
83
+ require 'fake_idp'
84
+
85
+ before(:each) do
86
+ FakeIdp.configure do |config|
87
+ config.callback_url = callback_url
88
+ config.sso_uid = sso_uid
89
+ config.name_id = name_id
90
+ end
91
+ end
92
+
93
+ it 'logs the sso user in' do
94
+ Capybara::Discoball.spin(FakeIdp::Application) do |fake_idp_server|
95
+ # ...
96
+ end
97
+ end
98
+ ```
99
+
100
+ ### Generating a SAML Response
101
+
102
+ The gem provides a `SamlResponse` class used to generate a custom signed XML SAML response with an assertion that can be encrypted by setting `encryption_enabled` to `true`.
103
+
104
+ **Usage**
105
+
106
+ ```ruby
107
+ # Instantiate with your IDP settings, user attributes and service provider details
108
+ saml_response = FakeIdp::SamlResponse.new(
109
+ saml_acs_url: "http://localhost.dev:3000/auth/saml/devidp/callback",
110
+ saml_request_id: "_#{SecureRandom.uuid}",
111
+ name_id: "bob_builder@gmail.com",
112
+ issuer_uri: "http://publichost.dev:3000",
113
+ algorithm_name: :sha256,
114
+ certificate: "YOUR IDP CERTIFICATE HERE",
115
+ secret_key: "YOUR IDP SECRET KEY HERE",
116
+ encryption_enabled: false,
117
+ user_attributes: {
118
+ uuid: "12345",
119
+ username: "bob_builder",
120
+ first_name: "Bob",
121
+ last_name: "The Builder",
122
+ email: "bob_builder@gmail.com",
123
+ },
124
+ )
125
+
126
+ # Returns a signed XML SAML response document
127
+ saml_response.build
128
+ ```
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "fake_idp"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ set -vx
4
+
5
+ bundle exec rackup
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+ cp .env.example .env
8
+
9
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,6 @@
1
+ $LOAD_PATH << File.expand_path("../lib", __FILE__)
2
+ require "fake_idp"
3
+ require 'dotenv'
4
+
5
+ Dotenv.load
6
+ run FakeIdp::Application
@@ -0,0 +1,39 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'fake_idp/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "fake_idp"
8
+ spec.version = FakeIdp::VERSION
9
+ spec.authors = [
10
+ "Shelby Switzer",
11
+ "Tyler Willingham",
12
+ "Robyn-Dale Samuda",
13
+ "Chet Bortz",
14
+ ]
15
+ spec.email = ["engineering@healthify.us"]
16
+
17
+ spec.summary = 'Fake IDP to test SAML authentication'
18
+ spec.license = "MIT"
19
+
20
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ spec.bindir = "exe"
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency "nokogiri", ">= 1.10.5"
26
+ spec.add_dependency "builder", ">= 3.2.2"
27
+ spec.add_dependency "activesupport", "~> 5.2.4.3"
28
+ spec.add_dependency "xmlenc", ">= 0.7.1"
29
+
30
+ spec.add_development_dependency "bundler", "~> 2"
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "rspec", "~> 3.9.0"
33
+ spec.add_development_dependency "dotenv", "~> 1.0"
34
+ spec.add_development_dependency "pry", "~> 0.12.2"
35
+
36
+ spec.add_runtime_dependency "sinatra", "~> 2.0.0"
37
+ spec.add_runtime_dependency "ruby-saml-idp"
38
+ spec.add_runtime_dependency "ruby-saml", "~> 1.11.0"
39
+ end
@@ -0,0 +1,24 @@
1
+ require 'sinatra/base'
2
+ require 'ruby-saml-idp'
3
+ require 'zlib'
4
+ require 'tilt/erb'
5
+ require 'fake_idp/configuration'
6
+ require 'fake_idp/application'
7
+
8
+ module FakeIdp
9
+ class << self
10
+ attr_writer :configuration
11
+ end
12
+
13
+ def self.configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def self.configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def self.reset!
22
+ @configuration = nil
23
+ end
24
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./saml_response"
4
+ require "ruby-saml"
5
+
6
+ module FakeIdp
7
+ class Application < Sinatra::Base
8
+ include SamlIdp::Controller
9
+
10
+ get "/saml/auth" do
11
+ begin
12
+ decode_SAMLRequest(generate_saml_request)
13
+ @saml_response = Base64.encode64(build_xml_saml_response).delete("\r\n")
14
+
15
+ erb :auth
16
+ rescue => e
17
+ puts e
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def configuration
24
+ FakeIdp.configuration
25
+ end
26
+
27
+ def build_xml_saml_response
28
+ FakeIdp::SamlResponse.new(
29
+ name_id: configuration.name_id,
30
+ issuer_uri: configuration.issuer,
31
+ saml_acs_url: @saml_acs_url, # Defined in #decode_SAMLRequest in ruby-saml-idp gem
32
+ saml_request_id: @saml_request_id, # Defined in #decode_SAMLRequest in ruby-saml-idp gem
33
+ user_attributes: user_attributes,
34
+ algorithm_name: configuration.algorithm,
35
+ certificate: configuration.idp_certificate,
36
+ secret_key: configuration.idp_secret_key,
37
+ encryption_enabled: configuration.encryption_enabled,
38
+ ).build
39
+ end
40
+
41
+ def user_attributes
42
+ {
43
+ uuid: configuration.sso_uid,
44
+ username: configuration.username,
45
+ first_name: configuration.first_name,
46
+ last_name: configuration.last_name,
47
+ email: configuration.email,
48
+ }.merge(configuration.additional_attributes)
49
+ end
50
+
51
+ # An AuthRequest is required by the ruby-saml-idp gem to begin the process of returning
52
+ # a SAMLResponse. We will likely remove the ruby-saml-idp dependency in a future update
53
+ def generate_saml_request
54
+ auth_request = OneLogin::RubySaml::Authrequest.new
55
+ auth_url = auth_request.create(saml_settings)
56
+ CGI.unescape(auth_url.split("=").last)
57
+ end
58
+
59
+ def saml_settings
60
+ OneLogin::RubySaml::Settings.new.tap do |setting|
61
+ setting.assertion_consumer_service_url = configuration.callback_url
62
+ setting.issuer = configuration.issuer
63
+ setting.idp_sso_target_url = configuration.idp_sso_target_url
64
+ setting.name_identifier_format = FakeIdp::SamlResponse::EMAIL_ADDRESS_FORMAT
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,65 @@
1
+ module FakeIdp
2
+ class Configuration
3
+ attr_accessor(
4
+ :callback_url,
5
+ :sso_uid,
6
+ :username,
7
+ :first_name,
8
+ :last_name,
9
+ :email,
10
+ :name_id,
11
+ :certificate,
12
+ :idp_certificate,
13
+ :idp_secret_key,
14
+ :idp_sso_target_url,
15
+ :issuer,
16
+ :algorithm,
17
+ :additional_attributes,
18
+ :encryption_enabled,
19
+ )
20
+
21
+ def initialize
22
+ @callback_url = ENV['CALLBACK_URL']
23
+ @sso_uid = ENV['SSO_UID']
24
+ @username = ENV['USERNAME']
25
+ @first_name = ENV['FIRST_NAME']
26
+ @last_name = ENV['LAST_NAME']
27
+ @email = ENV['EMAIL']
28
+ @name_id = ENV['NAME_ID']
29
+ @certificate = default_certificate
30
+ @idp_certificate = default_idp_certificate
31
+ @idp_secret_key = default_idp_secret_key
32
+ @idp_sso_target_url = idp_sso_target_url
33
+ @issuer = issuer
34
+ @algorithm = default_algorithm
35
+ @additional_attributes = {}
36
+ @encryption_enabled = default_encryption
37
+ end
38
+
39
+ private
40
+
41
+ def default_certificate
42
+ ENV["CERTIFICATE"] ||
43
+ SamlIdp::Default::X509_CERTIFICATE
44
+ end
45
+
46
+ def default_idp_certificate
47
+ ENV["IDP_CERTIFICATE"] ||
48
+ SamlIdp::Default::X509_CERTIFICATE
49
+ end
50
+
51
+ def default_idp_secret_key
52
+ ENV["IDP_SECRET_KEY"] ||
53
+ SamlIdp::Default::SECRET_KEY
54
+ end
55
+
56
+ def default_algorithm
57
+ ENV["ALGORITHM"]&.to_sym ||
58
+ :sha1
59
+ end
60
+
61
+ def default_encryption
62
+ ENV["ENCRYPTION_ENABLED"] == "true"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,90 @@
1
+ require "xmlenc"
2
+ require "builder"
3
+
4
+ module FakeIdp
5
+ class Encryptor
6
+ ENCRYPTION_STRATEGY = "aes256-cbc".freeze
7
+ KEY_TRANSPORT = "rsa-oaep-mgf1p".freeze
8
+
9
+ attr_reader :raw_xml, :certificate, :encryption_key
10
+
11
+ def initialize(raw_xml, certificate)
12
+ @raw_xml = raw_xml
13
+ @certificate = certificate
14
+ end
15
+
16
+ # Encryption approach borrowed from
17
+ # https://github.com/saml-idp/saml_idp/blob/master/lib/saml_idp/encryptor.rb
18
+ def encrypt
19
+ encryption_template = Nokogiri::XML::Document.parse(build_encryption_template).root
20
+ encrypted_data = Xmlenc::EncryptedData.new(encryption_template)
21
+ @encryption_key = encrypted_data.encrypt(raw_xml)
22
+ encrypted_key_node = encrypted_data.node.at_xpath(
23
+ "//xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey",
24
+ Xmlenc::NAMESPACES,
25
+ )
26
+ encrypted_key = Xmlenc::EncryptedKey.new(encrypted_key_node)
27
+ encrypted_key.encrypt(openssl_cert.public_key, encryption_key)
28
+
29
+ xml = Builder::XmlMarkup.new
30
+ xml.EncryptedAssertion(xmlns: "urn:oasis:names:tc:SAML:2.0:assertion") do |enc_assert|
31
+ enc_assert << encrypted_data.node.to_s
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def openssl_cert
38
+ @_openssl_cert ||= if certificate.is_a?(String)
39
+ OpenSSL::X509::Certificate.new(certificate)
40
+ else
41
+ certificate
42
+ end
43
+ end
44
+
45
+ def encryption_strategy_ns
46
+ "http://www.w3.org/2001/04/xmlenc##{ENCRYPTION_STRATEGY}"
47
+ end
48
+
49
+ def key_transport_ns
50
+ "http://www.w3.org/2001/04/xmlenc##{KEY_TRANSPORT}"
51
+ end
52
+
53
+ def build_encryption_template
54
+ xml = Builder::XmlMarkup.new
55
+ xml.EncryptedData(
56
+ Id: "ED",
57
+ Type: "http://www.w3.org/2001/04/xmlenc#Element",
58
+ xmlns: "http://www.w3.org/2001/04/xmlenc#",
59
+ ) do |enc_data|
60
+ enc_data.EncryptionMethod(Algorithm: encryption_strategy_ns)
61
+ enc_data.tag!("ds:KeyInfo", "xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#") do |key_info|
62
+ key_info.EncryptedKey(Id: "EK", xmlns: "http://www.w3.org/2001/04/xmlenc#") do |enc_key|
63
+ enc_key.EncryptionMethod(Algorithm: key_transport_ns)
64
+ enc_key.tag!("ds:KeyInfo", "xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#") do |key_info_child|
65
+ key_info_child.tag!("ds:KeyName")
66
+ key_info_child.tag!("ds:X509Data") do |x509_data|
67
+ x509_data.tag!("ds:X509Certificate") do |x509_cert|
68
+ x509_cert << certificate.to_s.gsub(/-+(BEGIN|END) CERTIFICATE-+/, "")
69
+ end
70
+ end
71
+ end
72
+
73
+ enc_key.CipherData(&:CipherValue)
74
+ enc_key.ReferenceList { |ref_list| ref_list.DataReference(URI: "#ED") }
75
+ end
76
+ end
77
+
78
+ enc_data.CipherData(&:CipherValue)
79
+ end
80
+ end
81
+
82
+ def encrypted_data_namespace
83
+ {
84
+ Id: "ED",
85
+ Type: "http://www.w3.org/2001/04/xmlenc#Element",
86
+ xmlns: "http://www.w3.org/2001/04/xmlenc#",
87
+ }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "nokogiri"
5
+ require "openssl"
6
+ require_relative "./encryptor"
7
+
8
+ module FakeIdp
9
+ class SamlResponse
10
+ DSIG = "http://www.w3.org/2000/09/xmldsig#"
11
+ SAML_VERSION = "2.0"
12
+ ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion"
13
+ ENTITY_FORMAT = "urn:oasis:names:SAML:2.0:nameid-format:entity"
14
+ BEARER_FORMAT = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
15
+ ENVELOPE_SCHEMA = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
16
+ STATUS_CODE_VALUE = "urn:oasis:names:tc:SAML:2.0:status:Success"
17
+ FEDERATION_SOURCE = "urn:federation:authentication:windows"
18
+ EMAIL_ADDRESS_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
19
+
20
+ # For the time being we're only supporting a single canonical schema since
21
+ # supporting multiple is inconsequential for our immediate need.
22
+ CANONICAL_VALUE = 1
23
+ CANONICAL_SCHEMA = "http://www.w3.org/2001/10/xml-exc-c14n#"
24
+
25
+ def initialize(
26
+ name_id:,
27
+ issuer_uri:,
28
+ saml_acs_url:,
29
+ saml_request_id:,
30
+ user_attributes:,
31
+ algorithm_name:,
32
+ certificate:,
33
+ secret_key:,
34
+ encryption_enabled: false
35
+ )
36
+ @name_id = name_id
37
+ @issuer_uri = issuer_uri
38
+ @saml_acs_url = saml_acs_url
39
+ @saml_request_id = saml_request_id
40
+ @user_attributes = user_attributes
41
+ @algorithm_name = algorithm_name
42
+ @certificate = certificate
43
+ @secret_key = secret_key
44
+ @encryption_enabled = encryption_enabled
45
+ @builder = Nokogiri::XML::Builder.new
46
+ @timestamp = Time.now
47
+ end
48
+
49
+ def build
50
+ @builder[:samlp].Response(root_namespace_attributes) do |response|
51
+ build_issuer_segment(response)
52
+ build_status_segment(response)
53
+ build_assertion_segment(response)
54
+ end
55
+
56
+ document_with_digest = replace_digest_value(@builder.to_xml)
57
+ document = replace_signature_value(document_with_digest)
58
+ encrypt_assertion!(document)
59
+ end
60
+
61
+ private
62
+
63
+ def encrypt_assertion!(document)
64
+ return document unless @encryption_enabled
65
+
66
+ document_copy = document.dup
67
+ working_document = Nokogiri::XML(document)
68
+ assertion = working_document.at_xpath("//saml:Assertion", "saml" => ASSERTION_NAMESPACE)
69
+ encrypted_assertion_xml = FakeIdp::Encryptor.new(
70
+ assertion.to_xml,
71
+ @certificate,
72
+ ).encrypt
73
+
74
+ document_copy = Nokogiri::XML(document_copy)
75
+ target_assertion_node = document_copy.at_xpath(
76
+ "//saml:Assertion",
77
+ "saml" => ASSERTION_NAMESPACE,
78
+ )
79
+ # Replace Assertion node with encrypted assertion
80
+ target_assertion_node.replace(encrypted_assertion_xml)
81
+ document_copy.to_xml
82
+ end
83
+
84
+ def replace_digest_value(document)
85
+ document_copy = document.dup
86
+ working_document = Nokogiri::XML(document)
87
+
88
+ # The signature element needs to be removed from the assertion before creating a digest
89
+ signature_element = working_document.at_xpath("//ds:Signature", "ds" => DSIG)
90
+ signature_element.remove
91
+
92
+ assertion_without_signature = working_document.
93
+ at_xpath("//*[@ID=$id]", nil, "id" => assertion_reference_response_id)
94
+ canon_hashed_element = assertion_without_signature.canonicalize(CANONICAL_VALUE)
95
+
96
+ digest_value = Base64.encode64(algorithm.digest(canon_hashed_element)).strip
97
+
98
+ # Replace digest node with the generated value
99
+ document_copy = Nokogiri::XML(document_copy)
100
+ target_digest_node = document_copy.at_xpath("//ds:DigestValue", "ds" => DSIG)
101
+ target_digest_node.content = digest_value
102
+ document_copy
103
+ end
104
+
105
+ def replace_signature_value(document)
106
+ document_copy = document.dup
107
+ signature_element = document.at_xpath("//ds:Signature", "ds" => DSIG)
108
+
109
+ # The SignatureValue is a signed copy of the SignedInfo element
110
+ signed_info_element = signature_element.at_xpath("./ds:SignedInfo", "ds" => DSIG)
111
+ canon_string = signed_info_element.canonicalize(CANONICAL_VALUE)
112
+
113
+ signature_value = sign(canon_string)
114
+
115
+ target_signature_node = document_copy.at_xpath("//ds:SignatureValue", "ds" => DSIG)
116
+ target_signature_node.content = signature_value
117
+ document_copy.to_xml
118
+ end
119
+
120
+ def build_issuer_segment(parent_attribute)
121
+ parent_attribute[:saml].Issuer("xmlns:saml" => ASSERTION_NAMESPACE) do |issuer|
122
+ issuer << @issuer_uri
123
+ end
124
+ end
125
+
126
+ def build_status_segment(parent_attribute)
127
+ parent_attribute[:samlp].Status do |status|
128
+ status[:samlp].StatusCode("Value" => STATUS_CODE_VALUE)
129
+ end
130
+ end
131
+
132
+ def build_assertion_segment(parent_attribute)
133
+ parent_attribute[:saml].Assertion(assertion_namespace_attributes) do |assertion|
134
+ assertion[:saml].Issuer("Format" => ENTITY_FORMAT) do |issuer|
135
+ issuer << @issuer_uri
136
+ end
137
+
138
+ build_assertion_signature(assertion)
139
+
140
+ assertion[:saml].Subject do |subject|
141
+ subject[:saml].NameID("Format" => EMAIL_ADDRESS_FORMAT) do |name_id|
142
+ name_id << @name_id
143
+ end
144
+
145
+ subject[:saml].SubjectConfirmation("Method" => BEARER_FORMAT) do |subject_confirmation|
146
+ subject_confirmation[:saml].SubjectConfirmationData(subject_confirmation_data) { "" }
147
+ end
148
+ end
149
+
150
+ assertion[:saml].Conditions(saml_conditions) do |conditions|
151
+ conditions[:saml].AudienceRestriction do |restriction|
152
+ restriction[:saml].Audience { |audience| audience << @issuer_uri }
153
+ end
154
+ end
155
+
156
+ assertion[:saml].AttributeStatement do |attribute_statement|
157
+ @user_attributes.map do |name, value|
158
+ attribute_statement[:saml].Attribute("Name" => name) do |attribute|
159
+ attribute[:saml].AttributeValue { |attribute_value| attribute_value << value }
160
+ end
161
+ end
162
+ end
163
+
164
+ assertion[:saml].AuthnStatement(authn_statement) do |statement|
165
+ statement[:saml].AuthnContext do |authn_context|
166
+ authn_context[:saml].AuthnContextClassRef do |context_class_ref|
167
+ context_class_ref << FEDERATION_SOURCE
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ def build_assertion_signature(parent_attribute)
175
+ parent_attribute[:ds].Signature("xmlns:ds" => DSIG) do |signature|
176
+ signature[:ds].SignedInfo("xmlns:ds" => DSIG) do |signed_info|
177
+ signed_info[:ds].CanonicalizationMethod("Algorithm" => CANONICAL_SCHEMA)
178
+ signed_info[:ds].SignatureMethod("Algorithm" => "#{DSIG}#{@algorithm_name}")
179
+
180
+ signed_info[:ds].Reference("URI" => reference_uri) do |reference|
181
+ reference[:ds].Transforms do |transform|
182
+ transform[:ds].Transform("Algorithm" => ENVELOPE_SCHEMA)
183
+ transform[:ds].Transform("Algorithm" => CANONICAL_SCHEMA)
184
+ end
185
+
186
+ reference[:ds].DigestMethod("Algorithm" => "#{DSIG}#{@algorithm_name}")
187
+
188
+ # The digest_value is set and derived from creating a digest of the Assertion element
189
+ # without the signature element after the document is generated
190
+ reference[:ds].DigestValue("xmlns:ds" => DSIG) { |d| d << "" }
191
+ end
192
+ end
193
+
194
+ # The signature_value is set and derived from signing the SignedInfo element after the
195
+ # document is generated
196
+ signature[:ds].SignatureValue { |signature_value| signature_value << "" }
197
+
198
+ signature.KeyInfo("xmlns:ds" => DSIG) do |key_info|
199
+ key_info[:ds].X509Data do |x509_data|
200
+ x509_data[:ds].X509Certificate do |x509_certificate|
201
+ x509_certificate << Base64.encode64(@certificate)
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ def algorithm
209
+ raise "Algorithm name must be a Symbol" unless @algorithm_name.is_a?(Symbol)
210
+
211
+ case @algorithm_name
212
+ when :sha256 then OpenSSL::Digest::SHA256
213
+ when :sha384 then OpenSSL::Digest::SHA384
214
+ when :sha512 then OpenSSL::Digest::SHA512
215
+ else
216
+ OpenSSL::Digest::SHA1
217
+ end
218
+ end
219
+
220
+ def sign(data)
221
+ key = OpenSSL::PKey::RSA.new(@secret_key)
222
+ Base64.encode64(key.sign(algorithm.new, data)).gsub(/\n/, "")
223
+ end
224
+
225
+ def reference_response_id
226
+ @_reference_response_id ||= "_#{SecureRandom.uuid}"
227
+ end
228
+
229
+ def assertion_reference_response_id
230
+ @assertion_reference_response_id ||= "_#{SecureRandom.uuid}"
231
+ end
232
+
233
+ def reference_uri
234
+ "_#{assertion_reference_response_id}"
235
+ end
236
+
237
+ def root_namespace_attributes
238
+ {
239
+ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
240
+ "Consent" => "urn:oasis:names:tc:SAML:2.0:consent:unspecified",
241
+ "Destination" => @saml_acs_url,
242
+ "ID" => reference_response_id,
243
+ "InResponseTo" => @saml_request_id,
244
+ "IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
245
+ "Version" => SAML_VERSION,
246
+ }
247
+ end
248
+
249
+ def assertion_namespace_attributes
250
+ {
251
+ "xmlns:saml" => ASSERTION_NAMESPACE,
252
+ "ID" => assertion_reference_response_id,
253
+ "IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
254
+ "Version" => SAML_VERSION,
255
+ }
256
+ end
257
+
258
+ def subject_confirmation_data
259
+ {
260
+ "InResponseTo" => @saml_request_id,
261
+ "NotOnOrAfter" => (@timestamp + 3 * 60).strftime("%Y-%m-%dT%H:%M:%S"),
262
+ "Recipient" => @saml_acs_url,
263
+ }
264
+ end
265
+
266
+ def saml_conditions
267
+ {
268
+ "NotBefore" => (@timestamp - 5).strftime("%Y-%m-%dT%H:%M:%S"),
269
+ "NotOnOrAfter" => (@timestamp + 60 * 60).strftime("%Y-%m-%dT%H:%M:%S"),
270
+ }
271
+ end
272
+
273
+ def authn_statement
274
+ {
275
+ "AuthnInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
276
+ "SessionIndex" => reference_response_id,
277
+ }
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,3 @@
1
+ module FakeIdp
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,18 @@
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>
8
+ <h1>Fake SAML SSO Authenticator</h1>
9
+
10
+ <form action="<%= @saml_acs_url %>" method="post">
11
+ <input name="SAMLResponse" type="hidden" value="<%= @saml_response %>">
12
+ <input type="submit" value="Login">
13
+ </form>
14
+
15
+ <h2>Encrypted Response</h2>
16
+ <pre><%= @saml_response.strip %></pre>
17
+ </body>
18
+ </html>
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Shelby Switzer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
metadata ADDED
@@ -0,0 +1,235 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fake_idp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Shelby Switzer
8
+ - Tyler Willingham
9
+ - Robyn-Dale Samuda
10
+ - Chet Bortz
11
+ autorequire:
12
+ bindir: exe
13
+ cert_chain: []
14
+ date: 2020-08-13 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: nokogiri
18
+ requirement: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.10.5
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 1.10.5
30
+ - !ruby/object:Gem::Dependency
31
+ name: builder
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 3.2.2
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 3.2.2
44
+ - !ruby/object:Gem::Dependency
45
+ name: activesupport
46
+ requirement: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - "~>"
49
+ - !ruby/object:Gem::Version
50
+ version: 5.2.4.3
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - "~>"
56
+ - !ruby/object:Gem::Version
57
+ version: 5.2.4.3
58
+ - !ruby/object:Gem::Dependency
59
+ name: xmlenc
60
+ requirement: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 0.7.1
65
+ type: :runtime
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 0.7.1
72
+ - !ruby/object:Gem::Dependency
73
+ name: bundler
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - "~>"
77
+ - !ruby/object:Gem::Version
78
+ version: '2'
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: '2'
86
+ - !ruby/object:Gem::Dependency
87
+ name: rake
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '13.0'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '13.0'
100
+ - !ruby/object:Gem::Dependency
101
+ name: rspec
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: 3.9.0
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: 3.9.0
114
+ - !ruby/object:Gem::Dependency
115
+ name: dotenv
116
+ requirement: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - "~>"
119
+ - !ruby/object:Gem::Version
120
+ version: '1.0'
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '1.0'
128
+ - !ruby/object:Gem::Dependency
129
+ name: pry
130
+ requirement: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - "~>"
133
+ - !ruby/object:Gem::Version
134
+ version: 0.12.2
135
+ type: :development
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - "~>"
140
+ - !ruby/object:Gem::Version
141
+ version: 0.12.2
142
+ - !ruby/object:Gem::Dependency
143
+ name: sinatra
144
+ requirement: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - "~>"
147
+ - !ruby/object:Gem::Version
148
+ version: 2.0.0
149
+ type: :runtime
150
+ prerelease: false
151
+ version_requirements: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - "~>"
154
+ - !ruby/object:Gem::Version
155
+ version: 2.0.0
156
+ - !ruby/object:Gem::Dependency
157
+ name: ruby-saml-idp
158
+ requirement: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ type: :runtime
164
+ prerelease: false
165
+ version_requirements: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ - !ruby/object:Gem::Dependency
171
+ name: ruby-saml
172
+ requirement: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - "~>"
175
+ - !ruby/object:Gem::Version
176
+ version: 1.11.0
177
+ type: :runtime
178
+ prerelease: false
179
+ version_requirements: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - "~>"
182
+ - !ruby/object:Gem::Version
183
+ version: 1.11.0
184
+ description:
185
+ email:
186
+ - engineering@healthify.us
187
+ executables: []
188
+ extensions: []
189
+ extra_rdoc_files: []
190
+ files:
191
+ - ".env.example"
192
+ - ".gitignore"
193
+ - ".ruby-version"
194
+ - ".travis.yml"
195
+ - Gemfile
196
+ - Gemfile.lock
197
+ - README.md
198
+ - Rakefile
199
+ - bin/console
200
+ - bin/server
201
+ - bin/setup
202
+ - config.ru
203
+ - fake_idp.gemspec
204
+ - lib/fake_idp.rb
205
+ - lib/fake_idp/application.rb
206
+ - lib/fake_idp/configuration.rb
207
+ - lib/fake_idp/encryptor.rb
208
+ - lib/fake_idp/saml_response.rb
209
+ - lib/fake_idp/version.rb
210
+ - lib/fake_idp/views/auth.erb
211
+ - license.txt
212
+ homepage:
213
+ licenses:
214
+ - MIT
215
+ metadata: {}
216
+ post_install_message:
217
+ rdoc_options: []
218
+ require_paths:
219
+ - lib
220
+ required_ruby_version: !ruby/object:Gem::Requirement
221
+ requirements:
222
+ - - ">="
223
+ - !ruby/object:Gem::Version
224
+ version: '0'
225
+ required_rubygems_version: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ requirements: []
231
+ rubygems_version: 3.0.6
232
+ signing_key:
233
+ specification_version: 4
234
+ summary: Fake IDP to test SAML authentication
235
+ test_files: []