Skip to content

Commit

Permalink
Merge pull request #20 from healthify/saml-response-generation-2
Browse files Browse the repository at this point in the history
SAML Response generator
  • Loading branch information
samudary authored Dec 6, 2019
2 parents 52467c0 + d95e4ca commit f746810
Show file tree
Hide file tree
Showing 5 changed files with 408 additions and 0 deletions.
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,23 @@ GEM
minitest (~> 5.1)
tzinfo (~> 1.1)
builder (3.2.3)
coderay (1.1.2)
concurrent-ruby (1.1.5)
diff-lcs (1.2.5)
dotenv (1.0.2)
i18n (1.7.0)
concurrent-ruby (~> 1.0)
macaddr (1.7.1)
systemu (~> 2.6.2)
method_source (0.9.2)
mini_portile2 (2.4.0)
minitest (5.13.0)
mustermann (1.0.3)
nokogiri (1.10.5)
mini_portile2 (~> 2.4.0)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
rack (2.0.7)
rack-protection (2.0.7)
rack
Expand All @@ -55,6 +60,8 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.4.0)
rspec-support (3.4.1)
ruby-saml (1.11.0)
nokogiri (>= 1.5.10)
sinatra (2.0.7)
mustermann (~> 1.0)
rack (~> 2.0)
Expand Down Expand Up @@ -82,8 +89,10 @@ DEPENDENCIES
bundler (~> 1.11)
dotenv (~> 1.0)
fake_idp!
pry (~> 0.12.2)
rake (~> 10.0)
rspec (~> 3.0)
ruby-saml (~> 1.11.0)
ruby-saml-idp!

BUNDLED WITH
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,36 @@ it 'logs the sso user in' do
end
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.

**Usage**

```ruby
# Instantiate with your IDP settings, user attributes and service provider details
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",
secret_key: "YOUR IDP SECRET KEY HERE",
encryption_enabled: false,
user_attributes: {
uuid: "12345",
username: "bob_builder",
first_name: "Bob",
last_name: "The Builder",
email: "[email protected]",
},
)

# Returns a signed unencrypted XML SAML response document
saml_response.build
```

**Note**: Encrypted assertions will be supported in a future update.
3 changes: 3 additions & 0 deletions fake_idp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ Gem::Specification.new do |spec|
spec.add_dependency "xmlenc", ">= 0.7.1"

spec.add_development_dependency "bundler", "~> 1.11"
spec.add_development_dependency "ruby-saml", "~> 1.11.0"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
spec.add_development_dependency "dotenv", "~> 1.0"
spec.add_development_dependency "pry", "~> 0.12.2"

spec.add_runtime_dependency "sinatra", "~> 2.0.0"
spec.add_runtime_dependency "ruby-saml-idp"
end
260 changes: 260 additions & 0 deletions lib/fake_idp/saml_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
# frozen_string_literal: true

require "securerandom"
require "nokogiri"
require "openssl"

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"
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"
STATUS_CODE_VALUE = "urn:oasis:names:tc:SAML:2.0:status:Success"
FEDERATION_SOURCE = "urn:federation:authentication:windows"
EMAIL_ADDRESS_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"

# For the time being we're only supporting a single canonical schema since
# supporting multiple is inconsequential for our immediate need.
CANONICAL_VALUE = 1
CANONICAL_SCHEMA = "http://www.w3.org/2001/10/xml-exc-c14n#"

def initialize(
name_id:,
audience_uri:,
issuer_uri:,
saml_acs_url:,
saml_request_id:,
user_attributes:,
algorithm_name:,
certificate:,
secret_key:,
encryption_enabled:
)
@name_id = name_id
@audience_uri = audience_uri
@issuer_uri = issuer_uri
@saml_acs_url = saml_acs_url
@saml_request_id = saml_request_id
@user_attributes = user_attributes
@algorithm_name = algorithm_name
@certificate = certificate
@secret_key = secret_key
@encryption_enabled = encryption_enabled
@builder = Nokogiri::XML::Builder.new
@timestamp = Time.now
end

def build
@builder[:samlp].Response(root_namespace_attributes) do |response|
build_issuer_segment(response)
build_status_segment(response)
build_assertion_segment(response)
end

document_with_digest = replace_digest_value(@builder.to_xml)
replace_signature_value(document_with_digest)
end

private

def replace_digest_value(document)
document_copy = document.dup
working_document = Nokogiri::XML(document)

# The signature element needs to be removed from the assertion before creating a digest
signature_element = working_document.at_xpath("//ds:Signature", "ds" => DSIG)
signature_element.remove

assertion_without_signature = working_document.
at_xpath("//*[@ID=$id]", nil, "id" => assertion_reference_response_id)
canon_hashed_element = assertion_without_signature.canonicalize(CANONICAL_VALUE)

digest_value = Base64.encode64(algorithm.digest(canon_hashed_element)).strip

# 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.content = digest_value
document_copy
end

def replace_signature_value(document)
document_copy = document.dup
signature_element = document.at_xpath("//ds:Signature", "ds" => DSIG)

# The SignatureValue is a signed copy of the SignedInfo element
signed_info_element = signature_element.at_xpath("./ds:SignedInfo", "ds" => DSIG)
canon_string = signed_info_element.canonicalize(CANONICAL_VALUE)

signature_value = sign(canon_string)

target_signature_node = document_copy.at_xpath("//ds:SignatureValue")
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|
issuer << @issuer_uri
end
end

def build_status_segment(parent_attribute)
parent_attribute[:samlp].Status do |status|
status[:samlp].StatusCode("Value" => STATUS_CODE_VALUE)
end
end

def build_assertion_segment(parent_attribute)
parent_attribute[:saml].Assertion(assertion_namespace_attributes) do |assertion|
assertion[:saml].Issuer("Format" => ENTITY_FORMAT) do |issuer|
issuer << @issuer_uri
end

build_assertion_signature(assertion)

assertion[:saml].Subject do |subject|
subject[:saml].NameID("Format" => EMAIL_ADDRESS_FORMAT) do |name_id|
name_id << @name_id
end

subject[:saml].SubjectConfirmation("Method" => BEARER_FORMAT) do |subject_confirmation|
subject_confirmation[:saml].SubjectConfirmationData(subject_confirmation_data) { "" }
end
end

assertion[:saml].Conditions(saml_conditions) do |conditions|
conditions[:saml].AudienceRestriction do |restriction|
restriction[:saml].Audience { |audience| audience << @issuer_uri }
end
end

assertion[:saml].AttributeStatement do |attribute_statement|
@user_attributes.map do |name, value|
attribute_statement[:saml].Attribute("Name" => name) do |attribute|
attribute[:saml].AttributeValue { |attribute_value| attribute_value << value }
end
end
end

assertion[:saml].AuthnStatement(authn_statement) do |statement|
statement[:saml].AuthnContext do |authn_context|
authn_context[:saml].AuthnContextClassRef do |context_class_ref|
context_class_ref << FEDERATION_SOURCE
end
end
end
end
end

def build_assertion_signature(parent_attribute)
parent_attribute[:ds].Signature("xmlns:ds" => DSIG) do |signature|
signature[:ds].SignedInfo("xmlns:ds" => DSIG) do |signed_info|
signed_info[:ds].CanonicalizationMethod("Algorithm" => CANONICAL_SCHEMA)
signed_info[:ds].SignatureMethod("Algorithm" => "#{DSIG}#{@algorithm_name}")

signed_info[:ds].Reference("URI" => reference_uri) do |reference|
reference[:ds].Transforms do |transform|
transform[:ds].Transform("Algorithm" => ENVELOPE_SCHEMA)
transform[:ds].Transform("Algorithm" => CANONICAL_SCHEMA)
end

reference[:ds].DigestMethod("Algorithm" => "#{DSIG}#{@algorithm_name}")

# 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 << "" }
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|
key_info[:ds].X509Data do |x509_data|
x509_data[:ds].X509Certificate do |x509_certificate|
x509_certificate << Base64.encode64(@certificate)
end
end
end
end
end

def algorithm
raise "Algorithm name must be a Symbol" unless @algorithm_name.is_a?(Symbol)

case @algorithm_name
when :sha256 then OpenSSL::Digest::SHA256
when :sha384 then OpenSSL::Digest::SHA384
when :sha512 then OpenSSL::Digest::SHA512
else
OpenSSL::Digest::SHA1
end
end

def sign(data)
key = OpenSSL::PKey::RSA.new(@secret_key)
Base64.encode64(key.sign(algorithm.new, data)).gsub(/\n/, "")
end

def reference_response_id
@_reference_response_id ||= "_#{SecureRandom.uuid}"
end

def assertion_reference_response_id
@assertion_reference_response_id ||= "_#{SecureRandom.uuid}"
end

def reference_uri
"_#{assertion_reference_response_id}"
end

def root_namespace_attributes
{
"xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
"Consent" => "urn:oasis:names:tc:SAML:2.0:consent:unspecified",
"Destination" => @saml_acs_url,
"ID" => reference_response_id,
"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,
"ID" => assertion_reference_response_id,
"IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
"Version" => SAML_VERSION,
}
end

def subject_confirmation_data
{
"InResponseTo" => @saml_request_id,
"NotOnOrAfter" => (@timestamp + 3 * 60).strftime("%Y-%m-%dT%H:%M:%S"),
"Recipient" => @saml_acs_url,
}
end

def saml_conditions
{
"NotBefore" => (@timestamp - 5).strftime("%Y-%m-%dT%H:%M:%S"),
"NotOnOrAfter" => (@timestamp + 60 * 60).strftime("%Y-%m-%dT%H:%M:%S"),
}
end

def authn_statement
{
"AuthnInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
"SessionIndex" => reference_response_id,
}
end
end
end
Loading

0 comments on commit f746810

Please sign in to comment.