lyrebird 1.0.1 → 2.0.0

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: a2c22dc89d09728d25d5563a9db0c136b144865ddea516ea71bd1806a4bf0ce2
4
- data.tar.gz: 002da134ef786dd04b2f61a3b2f01a2fe43ec4e772081b9d76dd4b4f5788e839
3
+ metadata.gz: 6b9457c5f260c26c5adf43de4262d67077e1f5729b4506ff0efa92ec61519346
4
+ data.tar.gz: 16a5dbf97973cd9c3673c26212fa0be146955ef9ddaf877768d40120416d6e54
5
5
  SHA512:
6
- metadata.gz: dc069741583e5940e825df140b405fe9c5710632c38927d2fb0baa80c52a4dafcb06170ac72a82259b9708f651e83e2ca11ee1d758d0031c1502f2d068faba57
7
- data.tar.gz: 78a80065561c35f30b81a588a9584c173b044064a9b73266385a6708109f0b02952e0acbcbc1d6768d1750ea47ebdcbf346f42dbc7b9e151e0731a43316c995a
6
+ metadata.gz: fc83af3b91fb52553dc96733c2a8ea0e89c561fe124bddc1b769cb39f681e03c1e18ff78ec2c83ffe886114b369e4a1630e637e56a1cf2dcee2137a5fe88680b
7
+ data.tar.gz: 138bd3e26e223c69dc10e435b21c12e0e1fcf3322e64df65d1775695d8920e6a274caf0d1323c5722244d73a47a09296d4d2a1d2037f2761084f470fcff6202c
data/README.md CHANGED
@@ -7,6 +7,7 @@ A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
7
7
  environments.
8
8
 
9
9
  ## Installation
10
+ Requires Ruby 3.2 or newer.
10
11
  ```ruby
11
12
  gem "lyrebird", group: :test
12
13
  ```
@@ -15,10 +16,6 @@ gem "lyrebird", group: :test
15
16
  ```ruby
16
17
  # test/integration/saml_test.rb
17
18
  class SAMLTest < ActionDispatch::IntegrationTest
18
- setup do
19
- @idp_cert = Lyrebird::Certificate.build
20
- end
21
-
22
19
  test "consume creates a session" do
23
20
  user = users(:alice)
24
21
 
@@ -28,7 +25,7 @@ class SAMLTest < ActionDispatch::IntegrationTest
28
25
  r.recipient = saml_consume_url
29
26
  r.audience = root_url
30
27
  r.name_id = user.email
31
- r.sign_with = @idp_cert
28
+ r.sign_with = Lyrebird::Certificate.default
32
29
 
33
30
  r.attributes do |a|
34
31
  a.email = user.email
@@ -93,8 +90,12 @@ end
93
90
  ### Getting the encoded response
94
91
  ```ruby
95
92
  response.mimic # Base64-encoded SAML response (for POST binding)
96
- response.document # Nokogiri::XML::Document for inspection
93
+ response.to_xml # the same response as plain XML (for reading)
94
+ response.document # the response as a Nokogiri document (for searching)
97
95
  ```
96
+ Use `response.to_xml`, not `document.to_xml`. Going through `document`
97
+ adds spaces and line breaks, which breaks the signature, and the SP
98
+ rejects the response.
98
99
 
99
100
  ### Signing
100
101
  Sign both the assertion and response with an IdP certificate:
@@ -117,6 +118,35 @@ response = Lyrebird::Response.build do |r|
117
118
  r.encrypt_with = sp_cert
118
119
  end
119
120
  ```
121
+ The assertion is signed first and then encrypted, and the response is
122
+ signed over the ciphertext.
123
+
124
+ ### Testing rejection paths
125
+ Build deliberately invalid responses the SP should reject:
126
+ ```ruby
127
+ # Expired (NotOnOrAfter in the past)
128
+ response = Lyrebird::Response.build do |r|
129
+ r.sign_with = idp_cert
130
+ r.valid_for = -10
131
+ end
132
+
133
+ # Not yet valid (NotBefore in the future)
134
+ response = Lyrebird::Response.build do |r|
135
+ r.sign_with = idp_cert
136
+ r.not_before = Time.now.utc + 600
137
+ end
138
+
139
+ # Wrong audience
140
+ response = Lyrebird::Response.build do |r|
141
+ r.sign_with = idp_cert
142
+ r.audience = "https://wrong.example.com"
143
+ end
144
+
145
+ # Untrusted signature (a certificate the SP does not know)
146
+ response = Lyrebird::Response.build do |r|
147
+ r.sign_with = Lyrebird::Certificate.build
148
+ end
149
+ ```
120
150
 
121
151
  ### NameID Formats
122
152
  ```ruby
@@ -139,13 +169,19 @@ Lyrebird.configure do |d|
139
169
  d.attributes = { role: "user" }
140
170
  end
141
171
  ```
142
- Defaults are frozen after configuration for thread safety.
172
+ Defaults are frozen after configuration for thread safety, so
173
+ `configure` can only be called once.
143
174
 
144
175
  ## Certificate
145
176
  Generates and manages X.509 certificates for signing SAML responses.
146
177
 
147
178
  ### Building a certificate
179
+ Key generation is slow, so `Lyrebird::Certificate.default` generates one
180
+ certificate and reuses it.
148
181
  ```ruby
182
+ # Generated once and reused
183
+ cert = Lyrebird::Certificate.default
184
+
149
185
  # With defaults
150
186
  cert = Lyrebird::Certificate.build
151
187
 
@@ -169,10 +205,11 @@ cert = Lyrebird::Certificate.load(
169
205
 
170
206
  ### Using a certificate
171
207
  ```ruby
172
- cert.key # OpenSSL::PKey::RSA private key
173
- cert.x509 # OpenSSL::X509::Certificate object
174
- cert.key_pem # PEM-encoded private key
175
- cert.x509_pem # PEM-encoded certificate
176
- cert.base64 # Base64-encoded certificate (for SAML metadata)
177
- cert.fingerprint # SHA256 fingerprint
208
+ cert.key # OpenSSL::PKey::RSA private key
209
+ cert.x509 # OpenSSL::X509::Certificate object
210
+ cert.key_pem # PEM-encoded private key
211
+ cert.x509_pem # PEM-encoded certificate
212
+ cert.base64 # Base64-encoded certificate (for SAML metadata)
213
+ cert.fingerprint # SHA256 fingerprint, colon-separated
214
+ cert.fingerprint(:sha1) # SHA1 fingerprint
178
215
  ```
@@ -20,8 +20,8 @@ module Lyrebird
20
20
  @name_id_format = name_id_format
21
21
  @recipient = recipient
22
22
  @in_response_to = in_response_to
23
- @not_before = not_before || @issue_instant
24
- @not_on_or_after = @issue_instant + valid_for
23
+ @not_before = (not_before || @issue_instant).to_time.getutc
24
+ @not_on_or_after = [@issue_instant, @not_before].max + valid_for
25
25
  @audience = audience
26
26
  @authn_context = authn_context
27
27
  @attributes = attributes
@@ -121,12 +121,14 @@ module Lyrebird
121
121
  as.namespace = ns
122
122
 
123
123
  @attributes.each do |name, values|
124
+ list = values.is_a?(Enumerable) ? values.to_a : [values].compact
125
+
124
126
  as.add_child(doc.create_element("Attribute")).tap do |a|
125
127
  a.namespace = ns
126
128
  a["Name"] = name
127
129
  a["NameFormat"] = ATTR_NAME_FORMAT
128
130
 
129
- Array(values).each do |value|
131
+ list.each do |value|
130
132
  a.add_child(doc.create_element("AttributeValue")).tap do |av|
131
133
  av.namespace = ns
132
134
  av.content = value
@@ -2,8 +2,19 @@
2
2
 
3
3
  module Lyrebird
4
4
  class Certificate
5
+ LOCK = Mutex.new
6
+
7
+ EXTENSIONS = {
8
+ "basicConstraints" => "CA:FALSE",
9
+ "keyUsage" => "digitalSignature,keyEncipherment"
10
+ }.freeze
11
+
5
12
  attr_reader :key, :x509
6
13
 
14
+ def self.default
15
+ LOCK.synchronize { @default ||= build }
16
+ end
17
+
7
18
  def self.build(**kwargs)
8
19
  config = OpenStruct.new(kwargs)
9
20
  yield config if block_given?
@@ -18,7 +29,7 @@ module Lyrebird
18
29
 
19
30
  def initialize(
20
31
  bits: 2048,
21
- cn: nil,
32
+ cn: "lyrebird",
22
33
  o: nil,
23
34
  valid_for: 365,
24
35
  valid_until: nil,
@@ -41,8 +52,9 @@ module Lyrebird
41
52
  @x509.to_pem
42
53
  end
43
54
 
44
- def fingerprint
45
- OpenSSL::Digest::SHA256.hexdigest(@x509.to_der)
55
+ def fingerprint(algorithm = :sha256)
56
+ digest = OpenSSL::Digest.new(algorithm.to_s)
57
+ digest.hexdigest(@x509.to_der).upcase.scan(/../).join(":")
46
58
  end
47
59
 
48
60
  def base64
@@ -62,6 +74,11 @@ module Lyrebird
62
74
  c.issuer = c.subject
63
75
  c.not_before = now
64
76
  c.not_after = @valid_until || now + (@valid_for * 86_400)
77
+
78
+ EXTENSIONS.each do |oid, value|
79
+ c.add_extension(OpenSSL::X509::Extension.new(oid, value, true))
80
+ end
81
+
65
82
  c.sign(@key, OpenSSL::Digest::SHA256.new)
66
83
  end
67
84
  end
@@ -49,7 +49,6 @@ module Lyrebird
49
49
 
50
50
  def build_encrypted_key
51
51
  @doc.create_element("EncryptedKey").tap do |ek|
52
- ek.add_namespace_definition("xenc", XMLENC_NS)
53
52
  ek.namespace = @xenc
54
53
 
55
54
  ek.add_child(@doc.create_element("EncryptionMethod")).tap do |em|
@@ -81,7 +80,8 @@ module Lyrebird
81
80
  cipher.encrypt
82
81
  cipher.key = @aes_key
83
82
  iv = cipher.random_iv
84
- ciphertext = cipher.update(@element.to_xml) + cipher.final
83
+ plaintext = @element.to_xml(save_with: 0)
84
+ ciphertext = cipher.update(plaintext) + cipher.final
85
85
 
86
86
  @doc.create_element("CipherData").tap do |cd|
87
87
  cd.namespace = @xenc
@@ -6,7 +6,11 @@ module Lyrebird
6
6
  config = OpenStruct.new(kwargs)
7
7
 
8
8
  config.define_singleton_method(:attributes) do |&block|
9
- self.attributes = OpenStruct.new.tap(&block).to_h
9
+ current = self[:attributes] || {}
10
+ return current unless block
11
+
12
+ fresh = OpenStruct.new.tap(&block).to_h
13
+ self[:attributes] = current.transform_keys(&:to_sym).merge(fresh)
10
14
  end
11
15
 
12
16
  yield config if block_given?
@@ -35,7 +39,11 @@ module Lyrebird
35
39
  end
36
40
 
37
41
  def mimic
38
- Base64.strict_encode64(document.to_xml(save_with: 0))
42
+ Base64.strict_encode64(to_xml)
43
+ end
44
+
45
+ def to_xml
46
+ document.to_xml(save_with: 0)
39
47
  end
40
48
 
41
49
  def document
@@ -66,13 +74,12 @@ module Lyrebird
66
74
  end
67
75
 
68
76
  r.add_child(build_status(doc))
69
- r.add_child(build_assertion_element(doc))
77
+ r.add_child(build_assertion_element)
70
78
  end
71
79
  end
72
80
 
73
- def build_assertion_element(doc)
74
- assertion_doc = @assertion.document
75
- element = assertion_doc.root
81
+ def build_assertion_element
82
+ element = @assertion.document.root
76
83
 
77
84
  if @encrypt_with
78
85
  Signature.new(element, @sign_with).sign! if @sign_with
@@ -11,7 +11,9 @@ module Lyrebird
11
11
  end
12
12
 
13
13
  def sign!
14
- issuer = @element.at_xpath("saml:Issuer", "saml" => SAML_ASSERTION_NS)
14
+ ns = { "saml" => SAML_ASSERTION_NS, "ds" => XMLDSIG_NS }
15
+ @element.at_xpath("ds:Signature", ns)&.remove
16
+ issuer = @element.at_xpath("saml:Issuer", ns)
15
17
  issuer.add_next_sibling(build_signature)
16
18
  populate_signature_value
17
19
  self
@@ -33,7 +35,6 @@ module Lyrebird
33
35
  def build_signed_info
34
36
  @doc.create_element("SignedInfo").tap do |si|
35
37
  @signed_info = si
36
- si.add_namespace_definition("ds", XMLDSIG_NS)
37
38
  si.namespace = @ds
38
39
 
39
40
  si.add_child(@doc.create_element("CanonicalizationMethod")).tap do |cm|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lyrebird
4
- VERSION = "1.0.1"
4
+ VERSION = "2.0.0"
5
5
  end
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.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-01 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -114,11 +114,8 @@ executables: []
114
114
  extensions: []
115
115
  extra_rdoc_files: []
116
116
  files:
117
- - ".github/workflows/ci.yml"
118
- - ".github/workflows/publish.yml"
119
117
  - LICENSE.md
120
118
  - README.md
121
- - Rakefile
122
119
  - lib/lyrebird.rb
123
120
  - lib/lyrebird/assertion.rb
124
121
  - lib/lyrebird/certificate.rb
@@ -129,22 +126,13 @@ files:
129
126
  - lib/lyrebird/response.rb
130
127
  - lib/lyrebird/signature.rb
131
128
  - lib/lyrebird/version.rb
132
- - lyrebird.gemspec
133
- - sig/lyrebird.rbs
134
- - test/lyrebird/assertion_test.rb
135
- - test/lyrebird/certificate_test.rb
136
- - test/lyrebird/defaults_test.rb
137
- - test/lyrebird/encryption_test.rb
138
- - test/lyrebird/id_test.rb
139
- - test/lyrebird/integration_test.rb
140
- - test/lyrebird/namespaces_test.rb
141
- - test/lyrebird/response_test.rb
142
- - test/lyrebird/signature_test.rb
143
- - test/test_helper.rb
144
129
  homepage: https://github.com/simplesaml/lyrebird
145
130
  licenses:
146
131
  - MIT
147
- metadata: {}
132
+ metadata:
133
+ source_code_uri: https://github.com/simplesaml/lyrebird
134
+ changelog_uri: https://github.com/simplesaml/lyrebird/releases
135
+ bug_tracker_uri: https://github.com/simplesaml/lyrebird/issues
148
136
  post_install_message:
149
137
  rdoc_options: []
150
138
  require_paths:
@@ -1,25 +0,0 @@
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
@@ -1,20 +0,0 @@
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/Rakefile DELETED
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
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
data/lyrebird.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/lyrebird/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "lyrebird"
7
- spec.version = Lyrebird::VERSION
8
- spec.authors = ["Josh"]
9
-
10
- spec.summary = "Mimics SAML Identity Provider (IdP) responses for testing"
11
- spec.homepage = "https://github.com/simplesaml/lyrebird"
12
- spec.license = "MIT"
13
- spec.required_ruby_version = ">= 3.2.0"
14
-
15
- spec.files = `git ls-files -z`.split("\x0")
16
- spec.files.delete("Gemfile")
17
- spec.files.delete(".gitignore")
18
- spec.files.reject! { |f| f.start_with?("bin/") }
19
-
20
- spec.require_paths = ["lib"]
21
-
22
- spec.add_dependency "base64"
23
- spec.add_dependency "nokogiri"
24
- spec.add_dependency "ostruct"
25
-
26
- # ruby-saml's gemspec has a conditional dependency on logger for Ruby >= 3.4,
27
- # but it was evaluated at gem build time (on Ruby < 3.4), so the published gem
28
- # doesn't include it.
29
- spec.add_development_dependency "logger"
30
- spec.add_development_dependency "minitest"
31
- spec.add_development_dependency "rake"
32
- spec.add_development_dependency "ruby-saml"
33
- end
data/sig/lyrebird.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Lyrebird
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end