lyrebird 1.0.0.alpha1 → 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: 01f50b61e7c1cf3b9417c22f495bfc6cd37c15cdd5bb6989147a4462c10ce7ca
4
- data.tar.gz: ee3ff1a24a14319f978685696a73df8f6dd0a321a9907ef0e0c47cb5b2344fdf
3
+ metadata.gz: b18c9229d638025f9882d18957a043c890e128ae5cbf9541d97f0cce078ce8e1
4
+ data.tar.gz: 28ce7ba82169a7fa5b1ac90299eb09af37781adefc74522a6ee43e16f822f740
5
5
  SHA512:
6
- metadata.gz: 98f2d39495b3f5db08e9f27e8663e9b9924ff55ee41b1b9a81a707aa91f6bfa452e823fb577702990bb05e9fdcd439e36573700f62ef30cbd4b48202cf7f4b98
7
- data.tar.gz: c526d72406244c4da1ba6cc9abe80abdd11191c7cba023db2a1a2b1bbd647a519aeb1531e49d4e09bd6701e1c6164a5b7969f141d005ff3109fe213bd0de0f1d
6
+ metadata.gz: 7edb0e358a6f295f80731bcf2d6544d55843b69c69bd25fcfbffc9a750240af558dfad598ba2939e6b9fa96033b17a7e827715908e08f3005ece03284c75f9a2
7
+ data.tar.gz: c7ba17cac0ffe23dcc264c52c6b8c6ce8fc44900bc0a302866e9c9aee2f874fd6f12ae5feda573c2d864047aac56a6bb5d67a000f570b018be54062b1e0acaab
data/README.md CHANGED
@@ -14,18 +14,19 @@ class SAMLTest < ActionDispatch::IntegrationTest
14
14
  test "consume creates a session" do
15
15
  user = users(:alice)
16
16
 
17
- response = Lyrebird::Response.new(
18
- issuer: "https://idp.example.com",
19
- destination: saml_consume_url,
20
- recipient: saml_consume_url,
21
- audience: root_url,
22
- name_id: user.email,
23
- attributes: {
24
- email: user.email,
25
- first_name: user.first_name,
26
- last_name: user.last_name
27
- }
28
- )
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
29
30
 
30
31
  post saml_consume_path, params: { SAMLResponse: response.mimic }
31
32
 
@@ -36,28 +37,48 @@ end
36
37
  ```
37
38
 
38
39
  ## Response
39
- Creates complete SAML responses with embedded assertions.
40
+ Builds complete SAML responses with embedded assertions.
40
41
 
41
- ### Creating a response
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`.
42
46
  ```ruby
43
- # With defaults
44
- response = Lyrebird::Response.new
47
+ # With defaults (SP-initiated)
48
+ response = Lyrebird::Response.build
45
49
 
46
50
  # With options
47
- response = Lyrebird::Response.new(
48
- issuer: "https://idp.example.com",
49
- destination: "https://sp.example.com/acs",
50
- in_response_to: "_request_id",
51
- name_id: "user@example.com",
52
- name_id_format: Lyrebird::NAMEID_EMAIL,
53
- recipient: "https://sp.example.com/acs",
54
- audience: "https://sp.example.com",
55
- valid_for: 300, # seconds
56
- attributes: {
57
- email: "user@example.com",
58
- groups: ["admin", "users"]
59
- }
60
- )
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
64
+
65
+ r.attributes do |a|
66
+ a.email = "user@example.com"
67
+ a.groups = ["admin", "users"]
68
+ end
69
+ end
70
+ ```
71
+
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
61
82
  ```
62
83
 
63
84
  ### Getting the encoded response
@@ -67,14 +88,25 @@ response.document # REXML::Document for inspection
67
88
  ```
68
89
 
69
90
  ### Signing
91
+ Sign both the assertion and response with an IdP certificate:
70
92
  ```ruby
71
- cert = Lyrebird::Certificate.generate
93
+ idp_cert = Lyrebird::Certificate.generate
94
+ response = Lyrebird::Response.build(sign_with: idp_cert)
95
+ ```
72
96
 
73
- response = Lyrebird::Response.new(
74
- certificate: cert,
75
- sign_assertion: true, # Sign the assertion (default: false)
76
- sign_response: true # Sign the response (default: false)
77
- )
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
+ ```
103
+
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
78
110
  ```
79
111
 
80
112
  ### NameID Formats
@@ -111,7 +143,7 @@ cert = Lyrebird::Certificate.generate(
111
143
  cn: "example.com", # Common Name
112
144
  o: "Acme", # Organization
113
145
  valid_for: 30, # Validity in days (default: 365)
114
- valid_until: Time.new(2026, 12, 31) # Specific expiration (overrides valid_for)
146
+ valid_until: Time.new(2999, 12, 31) # Specific expiration (overrides valid_for)
115
147
  )
116
148
  ```
117
149
 
@@ -8,19 +8,19 @@ module Lyrebird
8
8
  name_id_format: DEFAULTS.name_id_format,
9
9
  recipient: DEFAULTS.recipient,
10
10
  in_response_to: DEFAULTS.in_response_to,
11
+ not_before: nil,
11
12
  valid_for: DEFAULTS.valid_for,
12
13
  audience: DEFAULTS.audience,
13
14
  authn_context: DEFAULTS.authn_context,
14
15
  attributes: DEFAULTS.attributes
15
16
  )
16
- @id = ID.generate
17
- @session_index = ID.generate
18
17
  @issue_instant = Time.now.utc
19
18
  @issuer = issuer
20
19
  @name_id = name_id
21
20
  @name_id_format = name_id_format
22
21
  @recipient = recipient
23
22
  @in_response_to = in_response_to
23
+ @not_before = not_before || @issue_instant
24
24
  @not_on_or_after = @issue_instant + valid_for
25
25
  @audience = audience
26
26
  @authn_context = authn_context
@@ -38,7 +38,7 @@ module Lyrebird
38
38
  def root
39
39
  REXML::Element.new("saml:Assertion").tap do |r|
40
40
  r.add_namespace("saml", SAML_ASSERTION_NS)
41
- r.add_attribute("ID", @id)
41
+ r.add_attribute("ID", ID.generate)
42
42
  r.add_attribute("Version", "2.0")
43
43
  r.add_attribute("IssueInstant", @issue_instant.iso8601)
44
44
  r.add_element("saml:Issuer").text = @issuer
@@ -64,13 +64,13 @@ module Lyrebird
64
64
  data = sc.add_element("saml:SubjectConfirmationData")
65
65
  data.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
66
66
  data.add_attribute("Recipient", @recipient)
67
- data.add_attribute("InResponseTo", @in_response_to)
67
+ data.add_attribute("InResponseTo", @in_response_to) if @in_response_to
68
68
  end
69
69
  end
70
70
 
71
71
  def conditions
72
72
  REXML::Element.new("saml:Conditions").tap do |c|
73
- c.add_attribute("NotBefore", @issue_instant.iso8601)
73
+ c.add_attribute("NotBefore", @not_before.iso8601)
74
74
  c.add_attribute("NotOnOrAfter", @not_on_or_after.iso8601)
75
75
  ar = c.add_element("saml:AudienceRestriction")
76
76
  ar.add_element("saml:Audience").text = @audience
@@ -80,7 +80,7 @@ module Lyrebird
80
80
  def authn_statement
81
81
  REXML::Element.new("saml:AuthnStatement").tap do |as|
82
82
  as.add_attribute("AuthnInstant", @issue_instant.iso8601)
83
- as.add_attribute("SessionIndex", @session_index)
83
+ as.add_attribute("SessionIndex", ID.generate)
84
84
  ac = as.add_element("saml:AuthnContext")
85
85
  cr = ac.add_element("saml:AuthnContextClassRef")
86
86
  cr.text = @authn_context
@@ -25,7 +25,8 @@ module Lyrebird
25
25
  @private_key = private_key
26
26
  @common_name = cn
27
27
  @organization = o
28
- @not_after = valid_until || Time.now + (valid_for * 24 * 60 * 60)
28
+ @valid_for = valid_for
29
+ @valid_until = valid_until
29
30
  @certificate = certificate || build_certificate
30
31
  end
31
32
 
@@ -48,12 +49,14 @@ module Lyrebird
48
49
  private
49
50
 
50
51
  def build_certificate
52
+ now = Time.now
53
+
51
54
  OpenSSL::X509::Certificate.new.tap do |c|
52
55
  c.public_key = @private_key.public_key
53
56
  c.subject = build_subject
54
57
  c.issuer = c.subject
55
- c.not_before = Time.now
56
- c.not_after = @not_after
58
+ c.not_before = now
59
+ c.not_after = @valid_until || now + (@valid_for * 86_400)
57
60
  c.sign(@private_key, OpenSSL::Digest::SHA256.new)
58
61
  end
59
62
  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
@@ -11,4 +11,7 @@ module Lyrebird
11
11
  CM_BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
12
12
  ATTR_NAME_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"
13
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"
14
17
  end
@@ -2,23 +2,30 @@
2
2
 
3
3
  module Lyrebird
4
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
+
5
16
  def initialize(
6
17
  issuer: DEFAULTS.issuer,
7
18
  destination: DEFAULTS.recipient,
8
19
  in_response_to: DEFAULTS.in_response_to,
9
- certificate: nil,
10
- sign_assertion: false,
11
- sign_response: false,
20
+ sign_with: nil,
21
+ encrypt_with: nil,
12
22
  **assertion_options
13
23
  )
14
- @id = ID.generate
15
- @issue_instant = Time.now.utc
16
24
  @issuer = issuer
17
25
  @destination = destination
18
26
  @in_response_to = in_response_to
19
- @certificate = certificate
20
- @sign_assertion = sign_assertion
21
- @sign_response = sign_response
27
+ @sign_with = sign_with
28
+ @encrypt_with = encrypt_with
22
29
 
23
30
  @assertion = Assertion.new(
24
31
  issuer: issuer,
@@ -43,19 +50,25 @@ module Lyrebird
43
50
  REXML::Element.new("samlp:Response").tap do |r|
44
51
  r.add_namespace("samlp", SAML_PROTOCOL_NS)
45
52
  r.add_namespace("saml", SAML_ASSERTION_NS)
46
- r.add_attribute("ID", @id)
53
+ r.add_attribute("ID", ID.generate)
47
54
  r.add_attribute("Version", "2.0")
48
- r.add_attribute("IssueInstant", @issue_instant.iso8601)
49
- r.add_attribute("Destination", @destination)
50
- r.add_attribute("InResponseTo", @in_response_to)
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
51
58
  r.add_element("saml:Issuer").text = @issuer
52
59
  r.add_element(status)
53
- a = r.add_element(@assertion.document.root)
54
- Signature.new(a, certificate: @certificate).sign! if @sign_assertion
55
- Signature.new(r, certificate: @certificate).sign! if @sign_response
60
+ r.add_element(assertion_element)
61
+ Signature.new(r, @sign_with).sign! if @sign_with
56
62
  end
57
63
  end
58
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
+
59
72
  def status
60
73
  REXML::Element.new("samlp:Status").tap do |s|
61
74
  sc = s.add_element("samlp:StatusCode")
@@ -2,15 +2,15 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Signature
5
- def initialize(element, certificate:)
5
+ def initialize(element, certificate)
6
6
  @element = element
7
7
  @certificate = certificate
8
- @element_id = @element.attributes["ID"]
9
8
  end
10
9
 
11
10
  def sign!
12
11
  issuer = @element.elements["saml:Issuer"]
13
12
  @element.insert_after(issuer, signature_element)
13
+ self
14
14
  end
15
15
 
16
16
  private
@@ -50,7 +50,7 @@ module Lyrebird
50
50
 
51
51
  def reference
52
52
  REXML::Element.new("ds:Reference").tap do |ref|
53
- ref.add_attribute("URI", "##{@element_id}")
53
+ ref.add_attribute("URI", "##{@element.attributes["ID"]}")
54
54
  ref.add_element(transforms)
55
55
  dm = ref.add_element("ds:DigestMethod")
56
56
  dm.add_attribute("Algorithm", SHA256_DIGEST)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lyrebird
4
- VERSION = "1.0.0.alpha1"
4
+ VERSION = "1.0.0.alpha2"
5
5
  end
data/lib/lyrebird.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "base64"
4
4
  require "openssl"
5
+ require "ostruct"
5
6
  require "rexml"
6
7
  require "securerandom"
7
8
  require "time"
@@ -9,6 +10,7 @@ require "time"
9
10
  require_relative "lyrebird/assertion"
10
11
  require_relative "lyrebird/certificate"
11
12
  require_relative "lyrebird/defaults"
13
+ require_relative "lyrebird/encryption"
12
14
  require_relative "lyrebird/id"
13
15
  require_relative "lyrebird/namespaces"
14
16
  require_relative "lyrebird/response"
data/lyrebird.gemspec CHANGED
@@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
18
18
  spec.require_paths = ["lib"]
19
19
 
20
20
  spec.add_dependency "base64"
21
+ spec.add_dependency "ostruct"
21
22
  spec.add_dependency "rexml"
22
23
 
23
24
  spec.add_development_dependency "minitest"
@@ -131,6 +131,14 @@ module Lyrebird
131
131
  assert_equal in_response_to, scd.attributes["InResponseTo"]
132
132
  end
133
133
 
134
+ def test_in_response_to_omitted_when_nil
135
+ assertion = Assertion.new(in_response_to: nil).document
136
+ subject = assertion.root.elements["saml:Subject"]
137
+ sc = subject.elements["saml:SubjectConfirmation"]
138
+ scd = sc.elements["saml:SubjectConfirmationData"]
139
+ assert_nil scd.attributes["InResponseTo"]
140
+ end
141
+
134
142
  def test_valid_for_override
135
143
  valid_for = 600 # 10 minutes
136
144
  refute_equal valid_for, DEFAULTS.valid_for
@@ -153,6 +161,13 @@ module Lyrebird
153
161
  assert_in_delta Time.now.to_i, not_before.to_i, 1
154
162
  end
155
163
 
164
+ def test_conditions_not_before_override
165
+ not_before = Time.now.utc - 60
166
+ assertion = Assertion.new(not_before: not_before).document
167
+ conditions = assertion.root.elements["saml:Conditions"]
168
+ assert_equal not_before.iso8601, conditions.attributes["NotBefore"]
169
+ end
170
+
156
171
  def test_conditions_not_on_or_after
157
172
  not_on_or_after = Time.iso8601(@conditions.attributes["NotOnOrAfter"])
158
173
  expected = Time.now.utc + DEFAULTS.valid_for
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module Lyrebird
6
+ class EncryptionTest < Minitest::Test
7
+ def setup
8
+ @assertion = Assertion.new.document
9
+ @element = @assertion.root
10
+ @certificate = Certificate.generate
11
+ @encrypted = Encryption.new(@element, @certificate).encrypt
12
+ @ed = @encrypted.elements["xenc:EncryptedData"]
13
+ @ki = @ed.elements["ds:KeyInfo"]
14
+ @ek = @ki.elements["xenc:EncryptedKey"]
15
+ @cd = @ed.elements["xenc:CipherData"]
16
+ end
17
+
18
+ def test_returns_encrypted_assertion_element
19
+ assert_equal "EncryptedAssertion", @encrypted.name
20
+ assert_equal "saml", @encrypted.prefix
21
+ end
22
+
23
+ def test_encrypted_assertion_namespace
24
+ assert_equal SAML_ASSERTION_NS, @encrypted.namespace
25
+ end
26
+
27
+ def test_encrypted_data_element
28
+ assert_equal "EncryptedData", @ed.name
29
+ assert_equal "xenc", @ed.prefix
30
+ end
31
+
32
+ def test_encrypted_data_namespace
33
+ assert_equal XMLENC_NS, @ed.namespace
34
+ end
35
+
36
+ def test_encrypted_data_type
37
+ assert_equal "#{XMLENC_NS}Element", @ed.attributes["Type"]
38
+ end
39
+
40
+ def test_encryption_method_element
41
+ em = @ed.elements["xenc:EncryptionMethod"]
42
+ assert_equal "EncryptionMethod", em.name
43
+ assert_equal "xenc", em.prefix
44
+ end
45
+
46
+ def test_encryption_method_algorithm
47
+ em = @ed.elements["xenc:EncryptionMethod"]
48
+ assert_equal AES256_CBC, em.attributes["Algorithm"]
49
+ end
50
+
51
+ def test_cipher_data_element
52
+ assert_equal "CipherData", @cd.name
53
+ assert_equal "xenc", @cd.prefix
54
+ end
55
+
56
+ def test_cipher_value_element
57
+ cv = @cd.elements["xenc:CipherValue"]
58
+ assert_equal "CipherValue", cv.name
59
+ assert_equal "xenc", cv.prefix
60
+ end
61
+
62
+ def test_cipher_value_is_base64
63
+ cv = @cd.elements["xenc:CipherValue"]
64
+ decoded = Base64.strict_decode64(cv.text)
65
+ assert decoded.bytesize > 16
66
+ end
67
+
68
+ def test_cipher_value_starts_with_iv
69
+ cv = @cd.elements["xenc:CipherValue"]
70
+ decoded = Base64.strict_decode64(cv.text)
71
+ iv = decoded[0, 16]
72
+ assert_equal 16, iv.bytesize
73
+ end
74
+
75
+ def test_key_info_element
76
+ assert_equal "KeyInfo", @ki.name
77
+ assert_equal "ds", @ki.prefix
78
+ end
79
+
80
+ def test_key_info_namespace
81
+ assert_equal XMLDSIG_NS, @ki.namespace
82
+ end
83
+
84
+ def test_encrypted_key_element
85
+ assert_equal "EncryptedKey", @ek.name
86
+ assert_equal "xenc", @ek.prefix
87
+ end
88
+
89
+ def test_encrypted_key_namespace
90
+ assert_equal XMLENC_NS, @ek.namespace
91
+ end
92
+
93
+ def test_encrypted_key_encryption_method
94
+ em = @ek.elements["xenc:EncryptionMethod"]
95
+ assert_equal "EncryptionMethod", em.name
96
+ assert_equal RSA_OAEP, em.attributes["Algorithm"]
97
+ end
98
+
99
+ def test_encrypted_key_cipher_data
100
+ cd = @ek.elements["xenc:CipherData"]
101
+ assert_equal "CipherData", cd.name
102
+ assert_equal "xenc", cd.prefix
103
+ end
104
+
105
+ def test_encrypted_key_cipher_value
106
+ cv = @ek.elements["xenc:CipherData/xenc:CipherValue"]
107
+ assert_equal "CipherValue", cv.name
108
+ assert_equal "xenc", cv.prefix
109
+ end
110
+
111
+ def test_encrypted_key_cipher_value_is_base64
112
+ cv = @ek.elements["xenc:CipherData/xenc:CipherValue"]
113
+ decoded = Base64.strict_decode64(cv.text)
114
+ assert decoded.bytesize > 0
115
+ end
116
+
117
+ def test_encrypted_key_can_be_decrypted
118
+ cv = @ek.elements["xenc:CipherData/xenc:CipherValue"]
119
+ encrypted_key = Base64.strict_decode64(cv.text)
120
+ private_key = @certificate.private_key
121
+ padding = OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
122
+ decrypted_key = private_key.private_decrypt(encrypted_key, padding)
123
+ assert_equal 32, decrypted_key.bytesize
124
+ end
125
+ end
126
+ end
@@ -9,6 +9,78 @@ module Lyrebird
9
9
  @root = @response.document.root
10
10
  end
11
11
 
12
+ def test_build_with_defaults
13
+ response = Response.build
14
+ root = response.document.root
15
+ assert_equal DEFAULTS.issuer, root.elements["saml:Issuer"].text
16
+ end
17
+
18
+ def test_build_with_kwargs
19
+ issuer = "https://test.example.com"
20
+ refute_equal issuer, DEFAULTS.issuer
21
+ response = Response.build(issuer: issuer)
22
+ root = response.document.root
23
+ assert_equal issuer, root.elements["saml:Issuer"].text
24
+ end
25
+
26
+ def test_build_with_block
27
+ issuer = "https://test.example.com"
28
+ refute_equal issuer, DEFAULTS.issuer
29
+ response = Response.build { |r| r.issuer = issuer }
30
+ root = response.document.root
31
+ assert_equal issuer, root.elements["saml:Issuer"].text
32
+ end
33
+
34
+ def test_build_with_kwargs_and_block
35
+ issuer = "https://test.example.com"
36
+ email = "test@example.com"
37
+
38
+ refute_equal issuer, DEFAULTS.issuer
39
+ refute_equal email, DEFAULTS.name_id
40
+
41
+ response = Response.build(issuer: issuer) do |r|
42
+ r.name_id = email
43
+ end
44
+
45
+ root = response.document.root
46
+ assert_equal issuer, root.elements["saml:Issuer"].text
47
+ name_id = root.elements["saml:Assertion/saml:Subject/saml:NameID"]
48
+ assert_equal email, name_id.text
49
+ end
50
+
51
+ def test_build_with_attributes_block
52
+ email = "test@example.com"
53
+ role = "admin"
54
+
55
+ response = Response.build do |r|
56
+ r.attributes do |a|
57
+ a.email = email
58
+ a.role = role
59
+ end
60
+ end
61
+
62
+ root = response.document.root
63
+ statement = root.elements["saml:Assertion/saml:AttributeStatement"]
64
+ email_element = statement.elements["saml:Attribute[@Name='email']"]
65
+ role_element = statement.elements["saml:Attribute[@Name='role']"]
66
+
67
+ assert_equal email, email_element.elements["saml:AttributeValue"].text
68
+ assert_equal role, role_element.elements["saml:AttributeValue"].text
69
+ end
70
+
71
+ def test_build_with_attributes_hash
72
+ email = "user@example.com"
73
+
74
+ response = Response.build do |r|
75
+ r.attributes = { email: email }
76
+ end
77
+
78
+ root = response.document.root
79
+ statement = root.elements["saml:Assertion/saml:AttributeStatement"]
80
+ email_element = statement.elements["saml:Attribute[@Name='email']"]
81
+ assert_equal email, email_element.elements["saml:AttributeValue"].text
82
+ end
83
+
12
84
  def test_mimic_returns_base64
13
85
  encoded = @response.mimic
14
86
  decoded = Base64.strict_decode64(encoded)
@@ -64,6 +136,16 @@ module Lyrebird
64
136
  assert_equal in_response_to, root.attributes["InResponseTo"]
65
137
  end
66
138
 
139
+ def test_destination_omitted_when_nil
140
+ root = Response.new(destination: nil).document.root
141
+ assert_nil root.attributes["Destination"]
142
+ end
143
+
144
+ def test_in_response_to_omitted_when_nil
145
+ root = Response.new(in_response_to: nil).document.root
146
+ assert_nil root.attributes["InResponseTo"]
147
+ end
148
+
67
149
  def test_issuer
68
150
  issuer = @root.elements["saml:Issuer"]
69
151
  assert_equal "Issuer", issuer.name
@@ -121,48 +203,38 @@ module Lyrebird
121
203
  assert_equal email, name_id.text
122
204
  end
123
205
 
124
- def test_assertion_unsigned_by_default
125
- assertion = @root.elements["saml:Assertion"]
126
- assert_nil assertion.elements["ds:Signature"]
206
+ def test_unsigned_by_default
207
+ assert_nil @root.elements["ds:Signature"]
208
+ assert_nil @root.elements["saml:Assertion/ds:Signature"]
127
209
  end
128
210
 
129
- def test_sign_assertion_adds_signature
130
- cert = Certificate.generate
131
- root = Response.new(certificate: cert, sign_assertion: true).document.root
132
- assertion = root.elements["saml:Assertion"]
133
- signature = assertion.elements["ds:Signature"]
134
- assert_equal "Signature", signature.name
135
- assert_equal "ds", signature.prefix
211
+ def test_sign_with_signs_response_and_assertion
212
+ root = Response.new(sign_with: Certificate.generate).document.root
213
+ refute_nil root.elements["ds:Signature"]
214
+ refute_nil root.elements["saml:Assertion/ds:Signature"]
136
215
  end
137
216
 
138
- def test_response_unsigned_by_default
139
- assert_nil @root.elements["ds:Signature"]
217
+ def test_not_encrypted_by_default
218
+ assert_nil @root.elements["saml:EncryptedAssertion"]
140
219
  end
141
220
 
142
- def test_sign_response_adds_signature
143
- args = { certificate: Certificate.generate, sign_response: true }
144
- root = Response.new(**args).document.root
145
- signature = root.elements["ds:Signature"]
146
- assert_equal "Signature", signature.name
147
- assert_equal "ds", signature.prefix
221
+ def test_encrypt_with_creates_encrypted_assertion
222
+ root = Response.new(encrypt_with: Certificate.generate).document.root
223
+ ea = root.elements["saml:EncryptedAssertion"]
224
+ assert_equal "EncryptedAssertion", ea.name
225
+ assert_equal "saml", ea.prefix
148
226
  end
149
227
 
150
- def test_sign_both_response_and_assertion
151
- args = {
152
- certificate: Certificate.generate,
153
- sign_assertion: true,
154
- sign_response: true
155
- }
156
-
157
- root = Response.new(**args).document.root
158
- refute_nil root.elements["ds:Signature"]
159
- refute_nil root.elements["saml:Assertion/ds:Signature"]
228
+ def test_encrypt_with_removes_plain_assertion
229
+ root = Response.new(encrypt_with: Certificate.generate).document.root
230
+ assert_nil root.elements["saml:Assertion"]
160
231
  end
161
232
 
162
- def test_sign_neither_response_nor_assertion
163
- root = Response.new(certificate: Certificate.generate).document.root
164
- assert_nil root.elements["ds:Signature"]
165
- assert_nil root.elements["saml:Assertion/ds:Signature"]
233
+ def test_encrypt_with_contains_encrypted_data
234
+ root = Response.new(encrypt_with: Certificate.generate).document.root
235
+ ed = root.elements["saml:EncryptedAssertion/xenc:EncryptedData"]
236
+ assert_equal "EncryptedData", ed.name
166
237
  end
238
+
167
239
  end
168
240
  end
@@ -8,7 +8,7 @@ module Lyrebird
8
8
  @certificate = Certificate.generate
9
9
  @assertion = Assertion.new.document
10
10
  @element = @assertion.root
11
- Signature.new(@element, certificate: @certificate).sign!
11
+ Signature.new(@element, @certificate).sign!
12
12
  @signature = @element.elements["ds:Signature"]
13
13
  @signed_info = @signature.elements["ds:SignedInfo"]
14
14
  @reference = @signed_info.elements["ds:Reference"]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lyrebird
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.alpha1
4
+ version: 1.0.0.alpha2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-25 00:00:00.000000000 Z
11
+ date: 2026-01-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ostruct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rexml
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +94,7 @@ files:
80
94
  - lib/lyrebird/assertion.rb
81
95
  - lib/lyrebird/certificate.rb
82
96
  - lib/lyrebird/defaults.rb
97
+ - lib/lyrebird/encryption.rb
83
98
  - lib/lyrebird/id.rb
84
99
  - lib/lyrebird/namespaces.rb
85
100
  - lib/lyrebird/response.rb
@@ -90,6 +105,7 @@ files:
90
105
  - test/lyrebird/assertion_test.rb
91
106
  - test/lyrebird/certificate_test.rb
92
107
  - test/lyrebird/defaults_test.rb
108
+ - test/lyrebird/encryption_test.rb
93
109
  - test/lyrebird/id_test.rb
94
110
  - test/lyrebird/response_test.rb
95
111
  - test/lyrebird/signature_test.rb