lyrebird 0.0.0 → 1.0.0.alpha2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9634bd5e47288132993ae694000573ebe7a74035a43628f74053a68eb26ea785
4
- data.tar.gz: 0d733419004764bdf5b5d60cd2ed55fd6b7d4c7531c5fc37725e5c13a6e5eae9
3
+ metadata.gz: b18c9229d638025f9882d18957a043c890e128ae5cbf9541d97f0cce078ce8e1
4
+ data.tar.gz: 28ce7ba82169a7fa5b1ac90299eb09af37781adefc74522a6ee43e16f822f740
5
5
  SHA512:
6
- metadata.gz: 6053439212105edd8ab7b977c42d38a95678ed2f692f6fa19048b0e3dc3b8f822e0896e3a557fc59b3248d0040c71bc766252e2dcfe388b604a0feb0e953a8ad
7
- data.tar.gz: 75421cfad59640abbbe5f73729dca6df1dc14b400066cc6e281e0cd6432c8c94fe42cdc647adb25adccf4ffc232a240f6c069d27c6be7bc5008205d6675a8a24
6
+ metadata.gz: 7edb0e358a6f295f80731bcf2d6544d55843b69c69bd25fcfbffc9a750240af558dfad598ba2939e6b9fa96033b17a7e827715908e08f3005ece03284c75f9a2
7
+ data.tar.gz: c7ba17cac0ffe23dcc264c52c6b8c6ce8fc44900bc0a302866e9c9aee2f874fd6f12ae5feda573c2d864047aac56a6bb5d67a000f570b018be54062b1e0acaab
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby-version: ["3.2", "3.3", "3.4", "4.0"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Set up Ruby ${{ matrix.ruby-version }}
20
+ uses: ruby/setup-ruby@v1
21
+ with:
22
+ ruby-version: ${{ matrix.ruby-version }}
23
+ bundler-cache: true
24
+ - name: Run tests
25
+ run: bundle exec rake test
@@ -0,0 +1,20 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: "3.2"
15
+ - name: Build gem
16
+ run: gem build lyrebird.gemspec
17
+ - name: Publish to RubyGems
18
+ run: gem push lyrebird-*.gem
19
+ env:
20
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/README.md CHANGED
@@ -1,35 +1,166 @@
1
1
  # Lyrebird
2
+ A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
3
+ environments.
2
4
 
3
- TODO: Delete this and the text below, and describe your gem
5
+ ## Installation
6
+ ```ruby
7
+ gem "lyrebird", group: :test
8
+ ```
4
9
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/lyrebird`. To experiment with that code, run `bin/console` for an interactive prompt.
10
+ ## Basic example
11
+ ```ruby
12
+ # test/integration/saml_test.rb
13
+ class SAMLTest < ActionDispatch::IntegrationTest
14
+ test "consume creates a session" do
15
+ user = users(:alice)
6
16
 
7
- ## Installation
17
+ response = Lyrebird::Response.build do |r|
18
+ r.issuer = "https://idp.example.com"
19
+ r.destination = saml_consume_url
20
+ r.recipient = saml_consume_url
21
+ r.audience = root_url
22
+ r.name_id = user.email
23
+
24
+ r.attributes do |a|
25
+ a.email = user.email
26
+ a.first_name = user.first_name
27
+ a.last_name = user.last_name
28
+ end
29
+ end
30
+
31
+ post saml_consume_path, params: { SAMLResponse: response.mimic }
32
+
33
+ assert_redirected_to dashboard_path
34
+ assert_equal user.id, session[:user_id]
35
+ end
36
+ end
37
+ ```
38
+
39
+ ## Response
40
+ Builds complete SAML responses with embedded assertions.
41
+
42
+ ### Building a response
43
+ Defaults produce an SP-initiated response. See
44
+ [IdP-initiated SSO](#idp-initiated-sso) to omit `InResponseTo` and
45
+ `Destination`.
46
+ ```ruby
47
+ # With defaults (SP-initiated)
48
+ response = Lyrebird::Response.build
49
+
50
+ # With options
51
+ response = Lyrebird::Response.build do |r|
52
+ r.issuer = "https://idp.example.com"
53
+ r.destination = "https://sp.example.com/acs"
54
+ r.in_response_to = "_request_id"
55
+ r.name_id = "user@example.com"
56
+ r.name_id_format = Lyrebird::NAMEID_EMAIL
57
+ r.recipient = "https://sp.example.com/acs"
58
+ r.audience = "https://sp.example.com"
59
+ r.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
60
+ r.not_before = Time.now.utc
61
+ r.valid_for = 300 # seconds
62
+ r.sign_with = idp_cert
63
+ r.encrypt_with = sp_cert
8
64
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
65
+ r.attributes do |a|
66
+ a.email = "user@example.com"
67
+ a.groups = ["admin", "users"]
68
+ end
69
+ end
70
+ ```
10
71
 
11
- Install the gem and add to the application's Gemfile by executing:
72
+ ### IdP-initiated SSO
73
+ For unsolicited (IdP-initiated) flows where there is no AuthnRequest,
74
+ set `in_response_to` and `destination` to `nil` to omit them from the
75
+ XML entirely:
76
+ ```ruby
77
+ response = Lyrebird::Response.build do |r|
78
+ r.in_response_to = nil
79
+ r.destination = nil
80
+ r.name_id = "user@example.com"
81
+ end
82
+ ```
12
83
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
84
+ ### Getting the encoded response
85
+ ```ruby
86
+ response.mimic # Base64-encoded SAML response (for POST binding)
87
+ response.document # REXML::Document for inspection
15
88
  ```
16
89
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
90
+ ### Signing
91
+ Sign both the assertion and response with an IdP certificate:
92
+ ```ruby
93
+ idp_cert = Lyrebird::Certificate.generate
94
+ response = Lyrebird::Response.build(sign_with: idp_cert)
95
+ ```
96
+
97
+ ### Encryption
98
+ Encrypt assertions using the SP's certificate so only the SP can decrypt them:
99
+ ```ruby
100
+ sp_cert = Lyrebird::Certificate.generate # In practice, provided by the SP
101
+ response = Lyrebird::Response.build(encrypt_with: sp_cert)
102
+ ```
18
103
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
104
+ Signing and encryption can be combined:
105
+ ```ruby
106
+ response = Lyrebird::Response.build do |r|
107
+ r.sign_with = idp_cert
108
+ r.encrypt_with = sp_cert
109
+ end
21
110
  ```
22
111
 
23
- ## Usage
112
+ ### NameID Formats
113
+ ```ruby
114
+ Lyrebird::NAMEID_EMAIL # emailAddress (default)
115
+ Lyrebird::NAMEID_PERSISTENT # persistent
116
+ Lyrebird::NAMEID_TRANSIENT # transient
117
+ Lyrebird::NAMEID_UNSPECIFIED # unspecified
118
+ ```
24
119
 
25
- TODO: Write usage instructions here
120
+ ## Configuring defaults
121
+ Override defaults globally for all responses/assertions:
122
+ ```ruby
123
+ # test/test_helper.rb
124
+ Lyrebird::DEFAULTS.issuer = "https://custom.example.com"
125
+ Lyrebird::DEFAULTS.recipient = "https://custom.example.com/acs"
126
+ Lyrebird::DEFAULTS.audience = "https://custom.example.com"
127
+ Lyrebird::DEFAULTS.name_id = "default@example.com"
128
+ Lyrebird::DEFAULTS.valid_for = 600 # 10 minutes
129
+ Lyrebird::DEFAULTS.attributes = { role: "user" }
130
+ ```
26
131
 
27
- ## Development
132
+ ## Certificate
133
+ Generates and manages X.509 certificates for signing SAML responses.
28
134
 
29
- After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
135
+ ### Generating a new certificate
136
+ ```ruby
137
+ # With defaults
138
+ cert = Lyrebird::Certificate.generate
30
139
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
140
+ # With options
141
+ cert = Lyrebird::Certificate.generate(
142
+ bits: 4096, # RSA key size (default: 2048)
143
+ cn: "example.com", # Common Name
144
+ o: "Acme", # Organization
145
+ valid_for: 30, # Validity in days (default: 365)
146
+ valid_until: Time.new(2999, 12, 31) # Specific expiration (overrides valid_for)
147
+ )
148
+ ```
32
149
 
33
- ## Contributing
150
+ ### Loading an existing certificate
151
+ ```ruby
152
+ cert = Lyrebird::Certificate.load(
153
+ private_key_pem: File.read("private_key.pem"),
154
+ certificate_pem: File.read("certificate.pem")
155
+ )
156
+ ```
34
157
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/lyrebird.
158
+ ### Exporting
159
+ ```ruby
160
+ cert.private_key # OpenSSL::PKey::RSA object
161
+ cert.certificate # OpenSSL::X509::Certificate object
162
+ cert.private_key_pem # PEM-encoded private key
163
+ cert.certificate_pem # PEM-encoded certificate
164
+ cert.base64 # Base64-encoded certificate (for SAML metadata)
165
+ cert.fingerprint # SHA256 fingerprint
166
+ ```
data/Rakefile CHANGED
@@ -1,4 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs.push("test")
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Assertion
5
+ def initialize(
6
+ issuer: DEFAULTS.issuer,
7
+ name_id: DEFAULTS.name_id,
8
+ name_id_format: DEFAULTS.name_id_format,
9
+ recipient: DEFAULTS.recipient,
10
+ in_response_to: DEFAULTS.in_response_to,
11
+ not_before: nil,
12
+ valid_for: DEFAULTS.valid_for,
13
+ audience: DEFAULTS.audience,
14
+ authn_context: DEFAULTS.authn_context,
15
+ attributes: DEFAULTS.attributes
16
+ )
17
+ @issue_instant = Time.now.utc
18
+ @issuer = issuer
19
+ @name_id = name_id
20
+ @name_id_format = name_id_format
21
+ @recipient = recipient
22
+ @in_response_to = in_response_to
23
+ @not_before = not_before || @issue_instant
24
+ @not_on_or_after = @issue_instant + valid_for
25
+ @audience = audience
26
+ @authn_context = authn_context
27
+ @attributes = attributes
28
+ end
29
+
30
+ def document
31
+ REXML::Document.new.tap do |d|
32
+ d.add_element(root)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def root
39
+ REXML::Element.new("saml:Assertion").tap do |r|
40
+ r.add_namespace("saml", SAML_ASSERTION_NS)
41
+ r.add_attribute("ID", ID.generate)
42
+ r.add_attribute("Version", "2.0")
43
+ r.add_attribute("IssueInstant", @issue_instant.iso8601)
44
+ r.add_element("saml:Issuer").text = @issuer
45
+ r.add_element(subject)
46
+ r.add_element(conditions)
47
+ r.add_element(authn_statement)
48
+ r.add_element(attribute_statement) if @attributes.any?
49
+ end
50
+ end
51
+
52
+ def subject
53
+ REXML::Element.new("saml:Subject").tap do |s|
54
+ name_id = s.add_element("saml:NameID")
55
+ name_id.add_attribute("Format", @name_id_format)
56
+ name_id.text = @name_id
57
+ s.add_element(subject_confirmation)
58
+ end
59
+ end
60
+
61
+ def subject_confirmation
62
+ REXML::Element.new("saml:SubjectConfirmation").tap do |sc|
63
+ sc.add_attribute("Method", CM_BEARER)
64
+ data = sc.add_element("saml:SubjectConfirmationData")
65
+ data.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
66
+ data.add_attribute("Recipient", @recipient)
67
+ data.add_attribute("InResponseTo", @in_response_to) if @in_response_to
68
+ end
69
+ end
70
+
71
+ def conditions
72
+ REXML::Element.new("saml:Conditions").tap do |c|
73
+ c.add_attribute("NotBefore", @not_before.iso8601)
74
+ c.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
75
+ ar = c.add_element("saml:AudienceRestriction")
76
+ ar.add_element("saml:Audience").text = @audience
77
+ end
78
+ end
79
+
80
+ def authn_statement
81
+ REXML::Element.new("saml:AuthnStatement").tap do |as|
82
+ as.add_attribute("AuthnInstant", @issue_instant.iso8601)
83
+ as.add_attribute("SessionIndex", ID.generate)
84
+ ac = as.add_element("saml:AuthnContext")
85
+ cr = ac.add_element("saml:AuthnContextClassRef")
86
+ cr.text = @authn_context
87
+ end
88
+ end
89
+
90
+ def attribute_statement
91
+ REXML::Element.new("saml:AttributeStatement").tap do |as|
92
+ @attributes.each do |name, values|
93
+ a = as.add_element("saml:Attribute")
94
+ a.add_attribute("Name", name)
95
+ a.add_attribute("NameFormat", ATTR_NAME_FORMAT)
96
+
97
+ Array(values).each do |value|
98
+ a.add_element("saml:AttributeValue").text = value
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Certificate
5
+ attr_reader :private_key, :certificate
6
+
7
+ def self.generate(bits: 2048, **options)
8
+ new(OpenSSL::PKey::RSA.new(bits), **options)
9
+ end
10
+
11
+ def self.load(private_key_pem:, certificate_pem:)
12
+ private_key = OpenSSL::PKey::RSA.new(private_key_pem)
13
+ certificate = OpenSSL::X509::Certificate.new(certificate_pem)
14
+ new(private_key, certificate: certificate)
15
+ end
16
+
17
+ def initialize(
18
+ private_key,
19
+ cn: nil,
20
+ o: nil,
21
+ valid_for: 365,
22
+ valid_until: nil,
23
+ certificate: nil
24
+ )
25
+ @private_key = private_key
26
+ @common_name = cn
27
+ @organization = o
28
+ @valid_for = valid_for
29
+ @valid_until = valid_until
30
+ @certificate = certificate || build_certificate
31
+ end
32
+
33
+ def private_key_pem
34
+ @private_key.to_pem
35
+ end
36
+
37
+ def certificate_pem
38
+ @certificate.to_pem
39
+ end
40
+
41
+ def fingerprint
42
+ OpenSSL::Digest::SHA256.hexdigest(@certificate.to_der)
43
+ end
44
+
45
+ def base64
46
+ Base64.strict_encode64(@certificate.to_der)
47
+ end
48
+
49
+ private
50
+
51
+ def build_certificate
52
+ now = Time.now
53
+
54
+ OpenSSL::X509::Certificate.new.tap do |c|
55
+ c.public_key = @private_key.public_key
56
+ c.subject = build_subject
57
+ c.issuer = c.subject
58
+ c.not_before = now
59
+ c.not_after = @valid_until || now + (@valid_for * 86_400)
60
+ c.sign(@private_key, OpenSSL::Digest::SHA256.new)
61
+ end
62
+ end
63
+
64
+ def build_subject
65
+ OpenSSL::X509::Name.new.tap do |name|
66
+ name.add_entry("CN", @common_name) if @common_name
67
+ name.add_entry("O", @organization) if @organization
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ NAMEID_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
5
+ NAMEID_PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
6
+ NAMEID_TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
7
+ NAMEID_UNSPECIFIED = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
8
+
9
+ class Defaults
10
+ attr_accessor :issuer
11
+ attr_accessor :name_id
12
+ attr_accessor :name_id_format
13
+ attr_accessor :recipient
14
+ attr_accessor :in_response_to
15
+ attr_accessor :valid_for
16
+ attr_accessor :audience
17
+ attr_accessor :authn_context
18
+ attr_accessor :attributes
19
+
20
+ def initialize
21
+ @issuer = "https://idp.example.com"
22
+ @name_id = "user@example.com"
23
+ @name_id_format = NAMEID_EMAIL
24
+ @recipient = "https://sp.example.com/acs"
25
+ @in_response_to = "_request_id"
26
+ @valid_for = 300 # 5 minutes
27
+ @audience = "https://sp.example.com"
28
+
29
+ @authn_context =
30
+ "urn:oasis:names:tc:SAML:2.0:ac:classes:" \
31
+ "PasswordProtectedTransport"
32
+
33
+ @attributes = {
34
+ first_name: "Test",
35
+ last_name: "User",
36
+ }
37
+ end
38
+ end
39
+
40
+ DEFAULTS = Defaults.new
41
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Encryption
5
+ def initialize(element, certificate)
6
+ @element = element
7
+ @certificate = certificate
8
+ @aes_key = SecureRandom.random_bytes(32)
9
+ end
10
+
11
+ def encrypt
12
+ encrypted_assertion
13
+ end
14
+
15
+ private
16
+
17
+ def encrypted_assertion
18
+ REXML::Element.new("saml:EncryptedAssertion").tap do |ea|
19
+ ea.add_namespace("saml", SAML_ASSERTION_NS)
20
+ ea.add_element(encrypted_data)
21
+ end
22
+ end
23
+
24
+ def encrypted_data
25
+ REXML::Element.new("xenc:EncryptedData").tap do |ed|
26
+ ed.add_namespace("xenc", XMLENC_NS)
27
+ ed.add_attribute("Type", "#{XMLENC_NS}Element")
28
+ em = ed.add_element("xenc:EncryptionMethod")
29
+ em.add_attribute("Algorithm", AES256_CBC)
30
+ ed.add_element(key_info)
31
+ ed.add_element(cipher_data)
32
+ end
33
+ end
34
+
35
+ def key_info
36
+ REXML::Element.new("ds:KeyInfo").tap do |ki|
37
+ ki.add_namespace("ds", XMLDSIG_NS)
38
+ ki.add_element(encrypted_key)
39
+ end
40
+ end
41
+
42
+ def encrypted_key
43
+ REXML::Element.new("xenc:EncryptedKey").tap do |ek|
44
+ ek.add_namespace("xenc", XMLENC_NS)
45
+ em = ek.add_element("xenc:EncryptionMethod")
46
+ em.add_attribute("Algorithm", RSA_OAEP)
47
+ ek.add_element(encrypted_key_cipher_data)
48
+ end
49
+ end
50
+
51
+ def encrypted_key_cipher_data
52
+ public_key = @certificate.certificate.public_key
53
+ padding = OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
54
+ encrypted_aes_key = public_key.public_encrypt(@aes_key, padding)
55
+
56
+ REXML::Element.new("xenc:CipherData").tap do |cd|
57
+ cv = Base64.strict_encode64(encrypted_aes_key)
58
+ cd.add_element("xenc:CipherValue").text = cv
59
+ end
60
+ end
61
+
62
+ def cipher_data
63
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
64
+ cipher.encrypt
65
+ cipher.key = @aes_key
66
+ iv = cipher.random_iv
67
+ ciphertext = cipher.update(@element.to_s) + cipher.final
68
+
69
+ REXML::Element.new("xenc:CipherData").tap do |cd|
70
+ cv = Base64.strict_encode64(iv + ciphertext)
71
+ cd.add_element("xenc:CipherValue").text = cv
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ module ID
5
+ def self.generate
6
+ "_#{SecureRandom.uuid}"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
5
+ SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
6
+ XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#"
7
+ ENVELOPED_SIG = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
8
+ EXC_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#"
9
+ SHA256_DIGEST = "http://www.w3.org/2001/04/xmlenc#sha256"
10
+ RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
11
+ CM_BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
12
+ ATTR_NAME_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"
13
+ STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
14
+ XMLENC_NS = "http://www.w3.org/2001/04/xmlenc#"
15
+ AES256_CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
16
+ RSA_OAEP = "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"
17
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyrebird
4
+ class Response
5
+ def self.build(**kwargs)
6
+ config = OpenStruct.new(kwargs)
7
+
8
+ config.define_singleton_method(:attributes) do |&block|
9
+ self.attributes = OpenStruct.new.tap(&block).to_h
10
+ end
11
+
12
+ yield config if block_given?
13
+ new(**config.to_h)
14
+ end
15
+
16
+ def initialize(
17
+ issuer: DEFAULTS.issuer,
18
+ destination: DEFAULTS.recipient,
19
+ in_response_to: DEFAULTS.in_response_to,
20
+ sign_with: nil,
21
+ encrypt_with: nil,
22
+ **assertion_options
23
+ )
24
+ @issuer = issuer
25
+ @destination = destination
26
+ @in_response_to = in_response_to
27
+ @sign_with = sign_with
28
+ @encrypt_with = encrypt_with
29
+
30
+ @assertion = Assertion.new(
31
+ issuer: issuer,
32
+ in_response_to: in_response_to,
33
+ **assertion_options
34
+ )
35
+ end
36
+
37
+ def mimic
38
+ Base64.strict_encode64(document.to_s)
39
+ end
40
+
41
+ def document
42
+ REXML::Document.new.tap do |d|
43
+ d.add_element(root)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def root
50
+ REXML::Element.new("samlp:Response").tap do |r|
51
+ r.add_namespace("samlp", SAML_PROTOCOL_NS)
52
+ r.add_namespace("saml", SAML_ASSERTION_NS)
53
+ r.add_attribute("ID", ID.generate)
54
+ r.add_attribute("Version", "2.0")
55
+ r.add_attribute("IssueInstant", Time.now.utc.iso8601)
56
+ r.add_attribute("Destination", @destination) if @destination
57
+ r.add_attribute("InResponseTo", @in_response_to) if @in_response_to
58
+ r.add_element("saml:Issuer").text = @issuer
59
+ r.add_element(status)
60
+ r.add_element(assertion_element)
61
+ Signature.new(r, @sign_with).sign! if @sign_with
62
+ end
63
+ end
64
+
65
+ def assertion_element
66
+ element = @assertion.document.root
67
+ Signature.new(element, @sign_with).sign! if @sign_with
68
+ return element unless @encrypt_with
69
+ Encryption.new(element, @encrypt_with).encrypt
70
+ end
71
+
72
+ def status
73
+ REXML::Element.new("samlp:Status").tap do |s|
74
+ sc = s.add_element("samlp:StatusCode")
75
+ sc.add_attribute("Value", STATUS_SUCCESS)
76
+ end
77
+ end
78
+ end
79
+ end