Skip to content

Commit 29cfc5c

Browse files
authored
Merge pull request #31 from healthify/PP-696/encrypted-assertion-v2
Introduce option for encrypting SAML assertions
2 parents 549c6bb + 97daf65 commit 29cfc5c

8 files changed

+138
-99
lines changed

Diff for: README.md

+2-5
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ end
9999

100100
### Generating a SAML Response
101101

102-
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.
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`.
103103

104104
**Usage**
105105

@@ -109,7 +109,6 @@ saml_response = FakeIdp::SamlResponse.new(
109109
saml_acs_url: "http://localhost.dev:3000/auth/saml/devidp/callback",
110110
saml_request_id: "_#{SecureRandom.uuid}",
111111
name_id: "[email protected]",
112-
audience_uri: "http://localhost.dev:3000",
113112
issuer_uri: "http://publichost.dev:3000",
114113
algorithm_name: :sha256,
115114
certificate: "YOUR IDP CERTIFICATE HERE",
@@ -124,8 +123,6 @@ saml_response = FakeIdp::SamlResponse.new(
124123
},
125124
)
126125

127-
# Returns a signed unencrypted XML SAML response document
126+
# Returns a signed XML SAML response document
128127
saml_response.build
129128
```
130-
131-
**Note**: Encrypted assertions will be supported in a future update.

Diff for: lib/fake_idp/application.rb

+35-53
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "./saml_response"
4+
require "ruby-saml"
5+
16
module FakeIdp
27
class Application < Sinatra::Base
38
include SamlIdp::Controller
49

5-
get '/saml/auth' do
10+
get "/saml/auth" do
611
begin
7-
decode_SAMLRequest(mock_saml_request)
8-
@saml_acs_url = callback_url
9-
10-
configure_cert_and_keys
11-
12-
@saml_response = encode_SAMLResponse(
13-
name_id,
14-
attributes_provider: attributes_statement(user_attrs),
15-
)
12+
decode_SAMLRequest(generate_saml_request)
13+
@saml_response = Base64.encode64(build_xml_saml_response).delete("\r\n")
1614

1715
erb :auth
1816
rescue => e
@@ -26,60 +24,44 @@ def configuration
2624
FakeIdp.configuration
2725
end
2826

29-
def callback_url
30-
configuration.callback_url
31-
end
32-
33-
def configure_cert_and_keys
34-
self.x509_certificate = idp_certificate
35-
self.secret_key = configuration.idp_secret_key
36-
self.algorithm = configuration.algorithm
37-
end
38-
39-
def certificate
40-
Base64.encode64(configuration.certificate).delete("\n")
41-
end
42-
43-
def idp_certificate
44-
Base64.encode64(configuration.idp_certificate).delete("\n")
45-
end
46-
47-
def user_attrs
48-
signed_in_user_attrs.merge(configuration.additional_attributes)
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
4939
end
5040

51-
def signed_in_user_attrs
41+
def user_attributes
5242
{
5343
uuid: configuration.sso_uid,
5444
username: configuration.username,
5545
first_name: configuration.first_name,
5646
last_name: configuration.last_name,
57-
email: configuration.email
58-
}
59-
end
60-
61-
def name_id
62-
configuration.name_id
47+
email: configuration.email,
48+
}.merge(configuration.additional_attributes)
6349
end
6450

65-
def mock_saml_request
66-
current_gem_dir = File.dirname(__FILE__)
67-
sample_file_name = "#{current_gem_dir}/sample_init_request.txt"
68-
File.write(sample_file_name, params[:SAMLRequest]) if params[:SAMLRequest]
69-
File.read(sample_file_name).strip
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)
7057
end
7158

72-
def attributes_statement(attributes)
73-
attributes_xml = attributes_xml(attributes).join
74-
75-
%[<saml:AttributeStatement>#{attributes_xml}</saml:AttributeStatement>]
76-
end
77-
78-
def attributes_xml(attributes)
79-
attributes.map do |name, value|
80-
attribute_value = %[<saml:AttributeValue>#{value}</saml:AttributeValue>]
81-
82-
%[<saml:Attribute Name="#{name}">#{attribute_value}</saml:Attribute>]
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
8365
end
8466
end
8567
end

Diff for: lib/fake_idp/configuration.rb

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class Configuration
1111
:certificate,
1212
:idp_certificate,
1313
:idp_secret_key,
14+
:idp_sso_target_url,
15+
:issuer,
1416
:algorithm,
1517
:additional_attributes,
1618
:encryption_enabled,
@@ -27,6 +29,8 @@ def initialize
2729
@certificate = default_certificate
2830
@idp_certificate = default_idp_certificate
2931
@idp_secret_key = default_idp_secret_key
32+
@idp_sso_target_url = idp_sso_target_url
33+
@issuer = issuer
3034
@algorithm = default_algorithm
3135
@additional_attributes = {}
3236
@encryption_enabled = default_encryption

Diff for: lib/fake_idp/encryptor.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "xmlenc"
2+
require "builder"
23

34
module FakeIdp
45
class Encryptor
@@ -35,7 +36,7 @@ def encrypt
3536

3637
def openssl_cert
3738
@_openssl_cert ||= if certificate.is_a?(String)
38-
OpenSSL::X509::Certificate.new(Base64.decode64(certificate))
39+
OpenSSL::X509::Certificate.new(certificate)
3940
else
4041
certificate
4142
end

Diff for: lib/fake_idp/saml_response.rb

+32-12
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
require "securerandom"
44
require "nokogiri"
55
require "openssl"
6+
require_relative "./encryptor"
67

78
module FakeIdp
89
class SamlResponse
910
DSIG = "http://www.w3.org/2000/09/xmldsig#"
1011
SAML_VERSION = "2.0"
11-
ISSUER_VALUE = "urn:oasis:names:tc:SAML:2.0:assertion"
12+
ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion"
1213
ENTITY_FORMAT = "urn:oasis:names:SAML:2.0:nameid-format:entity"
1314
BEARER_FORMAT = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
1415
ENVELOPE_SCHEMA = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
@@ -23,18 +24,16 @@ class SamlResponse
2324

2425
def initialize(
2526
name_id:,
26-
audience_uri:,
2727
issuer_uri:,
2828
saml_acs_url:,
2929
saml_request_id:,
3030
user_attributes:,
3131
algorithm_name:,
3232
certificate:,
3333
secret_key:,
34-
encryption_enabled:
34+
encryption_enabled: false
3535
)
3636
@name_id = name_id
37-
@audience_uri = audience_uri
3837
@issuer_uri = issuer_uri
3938
@saml_acs_url = saml_acs_url
4039
@saml_request_id = saml_request_id
@@ -55,11 +54,33 @@ def build
5554
end
5655

5756
document_with_digest = replace_digest_value(@builder.to_xml)
58-
replace_signature_value(document_with_digest)
57+
document = replace_signature_value(document_with_digest)
58+
encrypt_assertion!(document)
5959
end
6060

6161
private
6262

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+
6384
def replace_digest_value(document)
6485
document_copy = document.dup
6586
working_document = Nokogiri::XML(document)
@@ -76,7 +97,7 @@ def replace_digest_value(document)
7697

7798
# Replace digest node with the generated value
7899
document_copy = Nokogiri::XML(document_copy)
79-
target_digest_node = document_copy.at_xpath("//ds:DigestValue")
100+
target_digest_node = document_copy.at_xpath("//ds:DigestValue", "ds" => DSIG)
80101
target_digest_node.content = digest_value
81102
document_copy
82103
end
@@ -91,13 +112,13 @@ def replace_signature_value(document)
91112

92113
signature_value = sign(canon_string)
93114

94-
target_signature_node = document_copy.at_xpath("//ds:SignatureValue")
115+
target_signature_node = document_copy.at_xpath("//ds:SignatureValue", "ds" => DSIG)
95116
target_signature_node.content = signature_value
96117
document_copy.to_xml
97118
end
98119

99120
def build_issuer_segment(parent_attribute)
100-
parent_attribute[:saml].Issuer("xmlns:saml" => ISSUER_VALUE) do |issuer|
121+
parent_attribute[:saml].Issuer("xmlns:saml" => ASSERTION_NAMESPACE) do |issuer|
101122
issuer << @issuer_uri
102123
end
103124
end
@@ -166,15 +187,15 @@ def build_assertion_signature(parent_attribute)
166187

167188
# The digest_value is set and derived from creating a digest of the Assertion element
168189
# without the signature element after the document is generated
169-
reference[:ds].DigestValue { |d| d << "" }
190+
reference[:ds].DigestValue("xmlns:ds" => DSIG) { |d| d << "" }
170191
end
171192
end
172193

173194
# The signature_value is set and derived from signing the SignedInfo element after the
174195
# document is generated
175196
signature[:ds].SignatureValue { |signature_value| signature_value << "" }
176197

177-
signature.KeyInfo("xmlns" => DSIG) do |key_info|
198+
signature.KeyInfo("xmlns:ds" => DSIG) do |key_info|
178199
key_info[:ds].X509Data do |x509_data|
179200
x509_data[:ds].X509Certificate do |x509_certificate|
180201
x509_certificate << Base64.encode64(@certificate)
@@ -222,13 +243,12 @@ def root_namespace_attributes
222243
"InResponseTo" => @saml_request_id,
223244
"IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
224245
"Version" => SAML_VERSION,
225-
"xmlns:ds" => DSIG,
226246
}
227247
end
228248

229249
def assertion_namespace_attributes
230250
{
231-
"xmlns:saml" => ISSUER_VALUE,
251+
"xmlns:saml" => ASSERTION_NAMESPACE,
232252
"ID" => assertion_reference_response_id,
233253
"IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"),
234254
"Version" => SAML_VERSION,

Diff for: lib/fake_idp/sample_init_request.txt

-1
This file was deleted.

Diff for: spec/encryptor_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
describe FakeIdp::Encryptor do
66
it "encrypts and decrypts XML" do
77
raw_xml = "<foo>bar</foo>"
8-
encryptor = described_class.new(raw_xml, Base64.encode64(fake_certificate).delete("\n"))
8+
encryptor = described_class.new(raw_xml, fake_certificate)
99
encrypted_xml = encryptor.encrypt
1010

1111
expect(encrypted_xml).to_not match raw_xml

0 commit comments

Comments
 (0)