diff --git a/README.md b/README.md
index 9b65258..5cd86c1 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ end
### Generating a SAML Response
-The gem provides a `SamlResponse` class used to generate a custom signed unencrypted XML SAML response. The SAML response is currently generated by the `ruby-saml-idp` gem and that functionality will be replaced with this class in a later update.
+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`.
**Usage**
@@ -109,7 +109,6 @@ saml_response = FakeIdp::SamlResponse.new(
saml_acs_url: "http://localhost.dev:3000/auth/saml/devidp/callback",
saml_request_id: "_#{SecureRandom.uuid}",
name_id: "bob_builder@gmail.com",
- audience_uri: "http://localhost.dev:3000",
issuer_uri: "http://publichost.dev:3000",
algorithm_name: :sha256,
certificate: "YOUR IDP CERTIFICATE HERE",
@@ -124,8 +123,6 @@ saml_response = FakeIdp::SamlResponse.new(
},
)
-# Returns a signed unencrypted XML SAML response document
+# Returns a signed XML SAML response document
saml_response.build
```
-
-**Note**: Encrypted assertions will be supported in a future update.
\ No newline at end of file
diff --git a/lib/fake_idp/application.rb b/lib/fake_idp/application.rb
index 42ea8d1..f2f283b 100644
--- a/lib/fake_idp/application.rb
+++ b/lib/fake_idp/application.rb
@@ -1,18 +1,16 @@
+# frozen_string_literal: true
+
+require_relative "./saml_response"
+require "ruby-saml"
+
module FakeIdp
class Application < Sinatra::Base
include SamlIdp::Controller
- get '/saml/auth' do
+ get "/saml/auth" do
begin
- decode_SAMLRequest(mock_saml_request)
- @saml_acs_url = callback_url
-
- configure_cert_and_keys
-
- @saml_response = encode_SAMLResponse(
- name_id,
- attributes_provider: attributes_statement(user_attrs),
- )
+ decode_SAMLRequest(generate_saml_request)
+ @saml_response = Base64.encode64(build_xml_saml_response).delete("\r\n")
erb :auth
rescue => e
@@ -26,60 +24,44 @@ def configuration
FakeIdp.configuration
end
- def callback_url
- configuration.callback_url
- end
-
- def configure_cert_and_keys
- self.x509_certificate = idp_certificate
- self.secret_key = configuration.idp_secret_key
- self.algorithm = configuration.algorithm
- end
-
- def certificate
- Base64.encode64(configuration.certificate).delete("\n")
- end
-
- def idp_certificate
- Base64.encode64(configuration.idp_certificate).delete("\n")
- end
-
- def user_attrs
- signed_in_user_attrs.merge(configuration.additional_attributes)
+ def build_xml_saml_response
+ FakeIdp::SamlResponse.new(
+ name_id: configuration.name_id,
+ issuer_uri: configuration.issuer,
+ saml_acs_url: @saml_acs_url, # Defined in #decode_SAMLRequest in ruby-saml-idp gem
+ saml_request_id: @saml_request_id, # Defined in #decode_SAMLRequest in ruby-saml-idp gem
+ user_attributes: user_attributes,
+ algorithm_name: configuration.algorithm,
+ certificate: configuration.idp_certificate,
+ secret_key: configuration.idp_secret_key,
+ encryption_enabled: configuration.encryption_enabled,
+ ).build
end
- def signed_in_user_attrs
+ def user_attributes
{
uuid: configuration.sso_uid,
username: configuration.username,
first_name: configuration.first_name,
last_name: configuration.last_name,
- email: configuration.email
- }
- end
-
- def name_id
- configuration.name_id
+ email: configuration.email,
+ }.merge(configuration.additional_attributes)
end
- def mock_saml_request
- current_gem_dir = File.dirname(__FILE__)
- sample_file_name = "#{current_gem_dir}/sample_init_request.txt"
- File.write(sample_file_name, params[:SAMLRequest]) if params[:SAMLRequest]
- File.read(sample_file_name).strip
+ # An AuthRequest is required by the ruby-saml-idp gem to begin the process of returning
+ # a SAMLResponse. We will likely remove the ruby-saml-idp dependency in a future update
+ def generate_saml_request
+ auth_request = OneLogin::RubySaml::Authrequest.new
+ auth_url = auth_request.create(saml_settings)
+ CGI.unescape(auth_url.split("=").last)
end
- def attributes_statement(attributes)
- attributes_xml = attributes_xml(attributes).join
-
- %[#{attributes_xml}]
- end
-
- def attributes_xml(attributes)
- attributes.map do |name, value|
- attribute_value = %[#{value}]
-
- %[#{attribute_value}]
+ def saml_settings
+ OneLogin::RubySaml::Settings.new.tap do |setting|
+ setting.assertion_consumer_service_url = configuration.callback_url
+ setting.issuer = configuration.issuer
+ setting.idp_sso_target_url = configuration.idp_sso_target_url
+ setting.name_identifier_format = FakeIdp::SamlResponse::EMAIL_ADDRESS_FORMAT
end
end
end
diff --git a/lib/fake_idp/configuration.rb b/lib/fake_idp/configuration.rb
index 470219b..33b9e9c 100644
--- a/lib/fake_idp/configuration.rb
+++ b/lib/fake_idp/configuration.rb
@@ -11,6 +11,8 @@ class Configuration
:certificate,
:idp_certificate,
:idp_secret_key,
+ :idp_sso_target_url,
+ :issuer,
:algorithm,
:additional_attributes,
:encryption_enabled,
@@ -27,6 +29,8 @@ def initialize
@certificate = default_certificate
@idp_certificate = default_idp_certificate
@idp_secret_key = default_idp_secret_key
+ @idp_sso_target_url = idp_sso_target_url
+ @issuer = issuer
@algorithm = default_algorithm
@additional_attributes = {}
@encryption_enabled = default_encryption
diff --git a/lib/fake_idp/encryptor.rb b/lib/fake_idp/encryptor.rb
index 962fbf7..8d9a7d8 100644
--- a/lib/fake_idp/encryptor.rb
+++ b/lib/fake_idp/encryptor.rb
@@ -1,4 +1,5 @@
require "xmlenc"
+require "builder"
module FakeIdp
class Encryptor
@@ -35,7 +36,7 @@ def encrypt
def openssl_cert
@_openssl_cert ||= if certificate.is_a?(String)
- OpenSSL::X509::Certificate.new(Base64.decode64(certificate))
+ OpenSSL::X509::Certificate.new(certificate)
else
certificate
end
diff --git a/lib/fake_idp/saml_response.rb b/lib/fake_idp/saml_response.rb
index a76fd94..05ec5eb 100644
--- a/lib/fake_idp/saml_response.rb
+++ b/lib/fake_idp/saml_response.rb
@@ -3,12 +3,13 @@
require "securerandom"
require "nokogiri"
require "openssl"
+require_relative "./encryptor"
module FakeIdp
class SamlResponse
DSIG = "http://www.w3.org/2000/09/xmldsig#"
SAML_VERSION = "2.0"
- ISSUER_VALUE = "urn:oasis:names:tc:SAML:2.0:assertion"
+ ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion"
ENTITY_FORMAT = "urn:oasis:names:SAML:2.0:nameid-format:entity"
BEARER_FORMAT = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
ENVELOPE_SCHEMA = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
@@ -23,7 +24,6 @@ class SamlResponse
def initialize(
name_id:,
- audience_uri:,
issuer_uri:,
saml_acs_url:,
saml_request_id:,
@@ -31,10 +31,9 @@ def initialize(
algorithm_name:,
certificate:,
secret_key:,
- encryption_enabled:
+ encryption_enabled: false
)
@name_id = name_id
- @audience_uri = audience_uri
@issuer_uri = issuer_uri
@saml_acs_url = saml_acs_url
@saml_request_id = saml_request_id
@@ -55,11 +54,33 @@ def build
end
document_with_digest = replace_digest_value(@builder.to_xml)
- replace_signature_value(document_with_digest)
+ document = replace_signature_value(document_with_digest)
+ encrypt_assertion!(document)
end
private
+ def encrypt_assertion!(document)
+ return document unless @encryption_enabled
+
+ document_copy = document.dup
+ working_document = Nokogiri::XML(document)
+ assertion = working_document.at_xpath("//saml:Assertion", "saml" => ASSERTION_NAMESPACE)
+ encrypted_assertion_xml = FakeIdp::Encryptor.new(
+ assertion.to_xml,
+ @certificate,
+ ).encrypt
+
+ document_copy = Nokogiri::XML(document_copy)
+ target_assertion_node = document_copy.at_xpath(
+ "//saml:Assertion",
+ "saml" => ASSERTION_NAMESPACE,
+ )
+ # Replace Assertion node with encrypted assertion
+ target_assertion_node.replace(encrypted_assertion_xml)
+ document_copy.to_xml
+ end
+
def replace_digest_value(document)
document_copy = document.dup
working_document = Nokogiri::XML(document)
@@ -76,7 +97,7 @@ def replace_digest_value(document)
# Replace digest node with the generated value
document_copy = Nokogiri::XML(document_copy)
- target_digest_node = document_copy.at_xpath("//ds:DigestValue")
+ target_digest_node = document_copy.at_xpath("//ds:DigestValue", "ds" => DSIG)
target_digest_node.content = digest_value
document_copy
end
@@ -91,13 +112,13 @@ def replace_signature_value(document)
signature_value = sign(canon_string)
- target_signature_node = document_copy.at_xpath("//ds:SignatureValue")
+ target_signature_node = document_copy.at_xpath("//ds:SignatureValue", "ds" => DSIG)
target_signature_node.content = signature_value
document_copy.to_xml
end
def build_issuer_segment(parent_attribute)
- parent_attribute[:saml].Issuer("xmlns:saml" => ISSUER_VALUE) do |issuer|
+ parent_attribute[:saml].Issuer("xmlns:saml" => ASSERTION_NAMESPACE) do |issuer|
issuer << @issuer_uri
end
end
@@ -166,7 +187,7 @@ def build_assertion_signature(parent_attribute)
# The digest_value is set and derived from creating a digest of the Assertion element
# without the signature element after the document is generated
- reference[:ds].DigestValue { |d| d << "" }
+ reference[:ds].DigestValue("xmlns:ds" => DSIG) { |d| d << "" }
end
end
@@ -174,7 +195,7 @@ def build_assertion_signature(parent_attribute)
# document is generated
signature[:ds].SignatureValue { |signature_value| signature_value << "" }
- signature.KeyInfo("xmlns" => DSIG) do |key_info|
+ signature.KeyInfo("xmlns:ds" => DSIG) do |key_info|
key_info[:ds].X509Data do |x509_data|
x509_data[:ds].X509Certificate do |x509_certificate|
x509_certificate << Base64.encode64(@certificate)
@@ -222,13 +243,12 @@ def root_namespace_attributes
"InResponseTo" => @saml_request_id,
"IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
"Version" => SAML_VERSION,
- "xmlns:ds" => DSIG,
}
end
def assertion_namespace_attributes
{
- "xmlns:saml" => ISSUER_VALUE,
+ "xmlns:saml" => ASSERTION_NAMESPACE,
"ID" => assertion_reference_response_id,
"IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
"Version" => SAML_VERSION,
diff --git a/lib/fake_idp/sample_init_request.txt b/lib/fake_idp/sample_init_request.txt
deleted file mode 100644
index 47b8894..0000000
--- a/lib/fake_idp/sample_init_request.txt
+++ /dev/null
@@ -1 +0,0 @@
-fZFLa8MwEIT/im86OZb8iJPFNpiEQiAtJX0ceimKrBCDLLlauY9/X9mmkELa6/LNzO5sgbxTPdSDO+uDfBskuqBGlNa1Rm+MxqGT9kHa91bIp8O+JGfneogiZQRXZ4MOEkppNLpEYsZJsPUureajxRVBmi3zWcB9Kgl225K8Jnmc5uuUhixNs5CyJAmPa7EMl/S0Ys1RiDWlHkUc5E6j49qVJKbMo3kYp48sA7aCLH4hwbO0OAXHC6/47JRGGNNKMlgNhmOLoHknEZyAh/p2Dx4E/nPzpaT/X9Nb44wwilTFSMO0na2uN1REl0wx137nPXfbe6Na8RXUSpmPjZXcyZI4O/gib4ztuPt7C7Zg06RtwtOEgux4q+qmsRKRRNWc+vu/1Tc=
diff --git a/spec/encryptor_spec.rb b/spec/encryptor_spec.rb
index ec38168..c4dd386 100644
--- a/spec/encryptor_spec.rb
+++ b/spec/encryptor_spec.rb
@@ -5,7 +5,7 @@
describe FakeIdp::Encryptor do
it "encrypts and decrypts XML" do
raw_xml = "bar"
- encryptor = described_class.new(raw_xml, Base64.encode64(fake_certificate).delete("\n"))
+ encryptor = described_class.new(raw_xml, fake_certificate)
encrypted_xml = encryptor.encrypt
expect(encrypted_xml).to_not match raw_xml
diff --git a/spec/saml_response_spec.rb b/spec/saml_response_spec.rb
index 6dffff2..697d8ba 100644
--- a/spec/saml_response_spec.rb
+++ b/spec/saml_response_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require_relative "spec_helper"
require_relative "../lib/fake_idp/saml_response"
require "ruby-saml"
@@ -12,7 +14,7 @@
config.name_id = "bobthessouser@example.com"
config.first_name = "Reid"
config.last_name = "Smith"
- configuration.email = "reid@msn.com"
+ config.email = "reid@msn.com"
config.idp_certificate = fake_public_certificate
config.idp_secret_key = fake_private_key
config.algorithm = :sha1
@@ -20,37 +22,71 @@
end
let(:configuration) { FakeIdp.configuration }
-
- it "generates a valid SAML response" do
- settings = OneLogin::RubySaml::Settings.new(
- allowed_clock_drift: 10000000,
+ let(:settings) do
+ OneLogin::RubySaml::Settings.new(
+ allowed_clock_drift: 10_000_000,
assertion_consumer_service_url: configuration.callback_url,
idp_cert: configuration.idp_certificate,
+ private_key: fake_private_key,
)
+ end
- saml_response = FakeIdp::SamlResponse.new(
- saml_acs_url: configuration.callback_url,
- saml_request_id: "_#{SecureRandom.uuid}",
- name_id: configuration.name_id,
- audience_uri: "http://localhost.dev:3000",
- issuer_uri: "http://publichost.dev:3000",
- algorithm_name: configuration.algorithm,
- certificate: configuration.idp_certificate,
- secret_key: configuration.idp_secret_key,
- encryption_enabled: false,
- user_attributes: {
- uuid: configuration.sso_uid,
- username: configuration.username,
- first_name: configuration.first_name,
- last_name: configuration.last_name,
- email: configuration.email,
- },
- ).build
+ context "encrypted assertion" do
+ it "generates a valid SAML response" do
+ saml_response = FakeIdp::SamlResponse.new(
+ saml_acs_url: configuration.callback_url,
+ saml_request_id: "_#{SecureRandom.uuid}",
+ name_id: configuration.name_id,
+ issuer_uri: "http://publichost.dev:3000",
+ algorithm_name: configuration.algorithm,
+ certificate: configuration.idp_certificate,
+ secret_key: configuration.idp_secret_key,
+ encryption_enabled: true,
+ user_attributes: {
+ uuid: configuration.sso_uid,
+ username: configuration.username,
+ first_name: configuration.first_name,
+ last_name: configuration.last_name,
+ email: configuration.email,
+ },
+ ).build
- response = OneLogin::RubySaml::Response.new(saml_response, settings: settings)
- response.is_valid?
+ response = OneLogin::RubySaml::Response.new(saml_response, settings: settings)
+ response.is_valid?
- expect(response.errors).to be_empty
+ expect(response.errors).to be_empty
+ expect(response.decrypted_document.to_s).to be_present
+ expect(response.document.to_s).to be_present
+ end
+ end
+
+ context "unencrypted assertion" do
+ it "generates a valid SAML response without a decrypted document value" do
+ saml_response = FakeIdp::SamlResponse.new(
+ saml_acs_url: configuration.callback_url,
+ saml_request_id: "_#{SecureRandom.uuid}",
+ name_id: configuration.name_id,
+ issuer_uri: "http://publichost.dev:3000",
+ algorithm_name: configuration.algorithm,
+ certificate: configuration.idp_certificate,
+ secret_key: configuration.idp_secret_key,
+ encryption_enabled: false,
+ user_attributes: {
+ uuid: configuration.sso_uid,
+ username: configuration.username,
+ first_name: configuration.first_name,
+ last_name: configuration.last_name,
+ email: configuration.email,
+ },
+ ).build
+
+ response = OneLogin::RubySaml::Response.new(saml_response, settings: settings)
+ response.is_valid?
+
+ expect(response.errors).to be_empty
+ expect(response.decrypted_document.to_s).to be_blank
+ expect(response.document.to_s).to be_present
+ end
end
def fake_public_certificate