Skip to content

Commit

Permalink
Merge pull request #31 from healthify/PP-696/encrypted-assertion-v2
Browse files Browse the repository at this point in the history
Introduce option for encrypting SAML assertions
  • Loading branch information
samudary authored Jun 9, 2020
2 parents 549c6bb + 97daf65 commit 29cfc5c
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 99 deletions.
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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: "[email protected]",
audience_uri: "http://localhost.dev:3000",
issuer_uri: "http://publichost.dev:3000",
algorithm_name: :sha256,
certificate: "YOUR IDP CERTIFICATE HERE",
Expand All @@ -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.
88 changes: 35 additions & 53 deletions lib/fake_idp/application.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

%[<saml:AttributeStatement>#{attributes_xml}</saml:AttributeStatement>]
end

def attributes_xml(attributes)
attributes.map do |name, value|
attribute_value = %[<saml:AttributeValue>#{value}</saml:AttributeValue>]

%[<saml:Attribute Name="#{name}">#{attribute_value}</saml:Attribute>]
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
Expand Down
4 changes: 4 additions & 0 deletions lib/fake_idp/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Configuration
:certificate,
:idp_certificate,
:idp_secret_key,
:idp_sso_target_url,
:issuer,
:algorithm,
:additional_attributes,
:encryption_enabled,
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/fake_idp/encryptor.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "xmlenc"
require "builder"

module FakeIdp
class Encryptor
Expand Down Expand Up @@ -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
Expand Down
44 changes: 32 additions & 12 deletions lib/fake_idp/saml_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,18 +24,16 @@ class SamlResponse

def initialize(
name_id:,
audience_uri:,
issuer_uri:,
saml_acs_url:,
saml_request_id:,
user_attributes:,
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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -166,15 +187,15 @@ 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

# The signature_value is set and derived from signing the SignedInfo element after the
# 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)
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion lib/fake_idp/sample_init_request.txt

This file was deleted.

2 changes: 1 addition & 1 deletion spec/encryptor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
describe FakeIdp::Encryptor do
it "encrypts and decrypts XML" do
raw_xml = "<foo>bar</foo>"
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
Expand Down
Loading

0 comments on commit 29cfc5c

Please sign in to comment.