lyrebird 0.0.0 → 1.0.0.alpha1
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 +4 -4
- data/.github/workflows/ci.yml +25 -0
- data/.github/workflows/publish.yml +20 -0
- data/README.md +116 -17
- data/Rakefile +8 -1
- data/lib/lyrebird/assertion.rb +104 -0
- data/lib/lyrebird/certificate.rb +68 -0
- data/lib/lyrebird/defaults.rb +41 -0
- data/lib/lyrebird/id.rb +9 -0
- data/lib/lyrebird/namespaces.rb +14 -0
- data/lib/lyrebird/response.rb +66 -0
- data/lib/lyrebird/signature.rb +75 -0
- data/lib/lyrebird/version.rb +1 -1
- data/lib/lyrebird.rb +13 -1
- data/lyrebird.gemspec +25 -0
- data/test/lyrebird/assertion_test.rb +314 -0
- data/test/lyrebird/certificate_test.rb +87 -0
- data/test/lyrebird/defaults_test.rb +11 -0
- data/test/lyrebird/id_test.rb +11 -0
- data/test/lyrebird/response_test.rb +168 -0
- data/test/lyrebird/signature_test.rb +137 -0
- data/test/test_helper.rb +4 -0
- metadata +87 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01f50b61e7c1cf3b9417c22f495bfc6cd37c15cdd5bb6989147a4462c10ce7ca
|
|
4
|
+
data.tar.gz: ee3ff1a24a14319f978685696a73df8f6dd0a321a9907ef0e0c47cb5b2344fdf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98f2d39495b3f5db08e9f27e8663e9b9924ff55ee41b1b9a81a707aa91f6bfa452e823fb577702990bb05e9fdcd439e36573700f62ef30cbd4b48202cf7f4b98
|
|
7
|
+
data.tar.gz: c526d72406244c4da1ba6cc9abe80abdd11191c7cba023db2a1a2b1bbd647a519aeb1531e49d4e09bd6701e1c6164a5b7969f141d005ff3109fe213bd0de0f1d
|
|
@@ -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,134 @@
|
|
|
1
1
|
# Lyrebird
|
|
2
|
+
A Ruby gem for mimicking SAML Identity Provider (IdP) responses in test
|
|
3
|
+
environments.
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
## Installation
|
|
6
|
+
```ruby
|
|
7
|
+
gem "lyrebird", group: :test
|
|
8
|
+
```
|
|
4
9
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
)
|
|
8
29
|
|
|
9
|
-
|
|
30
|
+
post saml_consume_path, params: { SAMLResponse: response.mimic }
|
|
10
31
|
|
|
11
|
-
|
|
32
|
+
assert_redirected_to dashboard_path
|
|
33
|
+
assert_equal user.id, session[:user_id]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
12
37
|
|
|
13
|
-
|
|
14
|
-
|
|
38
|
+
## Response
|
|
39
|
+
Creates complete SAML responses with embedded assertions.
|
|
40
|
+
|
|
41
|
+
### Creating a response
|
|
42
|
+
```ruby
|
|
43
|
+
# With defaults
|
|
44
|
+
response = Lyrebird::Response.new
|
|
45
|
+
|
|
46
|
+
# 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
|
+
)
|
|
15
61
|
```
|
|
16
62
|
|
|
17
|
-
|
|
63
|
+
### Getting the encoded response
|
|
64
|
+
```ruby
|
|
65
|
+
response.mimic # Base64-encoded SAML response (for POST binding)
|
|
66
|
+
response.document # REXML::Document for inspection
|
|
67
|
+
```
|
|
18
68
|
|
|
19
|
-
|
|
20
|
-
|
|
69
|
+
### Signing
|
|
70
|
+
```ruby
|
|
71
|
+
cert = Lyrebird::Certificate.generate
|
|
72
|
+
|
|
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
|
+
)
|
|
21
78
|
```
|
|
22
79
|
|
|
23
|
-
|
|
80
|
+
### NameID Formats
|
|
81
|
+
```ruby
|
|
82
|
+
Lyrebird::NAMEID_EMAIL # emailAddress (default)
|
|
83
|
+
Lyrebird::NAMEID_PERSISTENT # persistent
|
|
84
|
+
Lyrebird::NAMEID_TRANSIENT # transient
|
|
85
|
+
Lyrebird::NAMEID_UNSPECIFIED # unspecified
|
|
86
|
+
```
|
|
24
87
|
|
|
25
|
-
|
|
88
|
+
## Configuring defaults
|
|
89
|
+
Override defaults globally for all responses/assertions:
|
|
90
|
+
```ruby
|
|
91
|
+
# test/test_helper.rb
|
|
92
|
+
Lyrebird::DEFAULTS.issuer = "https://custom.example.com"
|
|
93
|
+
Lyrebird::DEFAULTS.recipient = "https://custom.example.com/acs"
|
|
94
|
+
Lyrebird::DEFAULTS.audience = "https://custom.example.com"
|
|
95
|
+
Lyrebird::DEFAULTS.name_id = "default@example.com"
|
|
96
|
+
Lyrebird::DEFAULTS.valid_for = 600 # 10 minutes
|
|
97
|
+
Lyrebird::DEFAULTS.attributes = { role: "user" }
|
|
98
|
+
```
|
|
26
99
|
|
|
27
|
-
##
|
|
100
|
+
## Certificate
|
|
101
|
+
Generates and manages X.509 certificates for signing SAML responses.
|
|
28
102
|
|
|
29
|
-
|
|
103
|
+
### Generating a new certificate
|
|
104
|
+
```ruby
|
|
105
|
+
# With defaults
|
|
106
|
+
cert = Lyrebird::Certificate.generate
|
|
30
107
|
|
|
31
|
-
|
|
108
|
+
# With options
|
|
109
|
+
cert = Lyrebird::Certificate.generate(
|
|
110
|
+
bits: 4096, # RSA key size (default: 2048)
|
|
111
|
+
cn: "example.com", # Common Name
|
|
112
|
+
o: "Acme", # Organization
|
|
113
|
+
valid_for: 30, # Validity in days (default: 365)
|
|
114
|
+
valid_until: Time.new(2026, 12, 31) # Specific expiration (overrides valid_for)
|
|
115
|
+
)
|
|
116
|
+
```
|
|
32
117
|
|
|
33
|
-
|
|
118
|
+
### Loading an existing certificate
|
|
119
|
+
```ruby
|
|
120
|
+
cert = Lyrebird::Certificate.load(
|
|
121
|
+
private_key_pem: File.read("private_key.pem"),
|
|
122
|
+
certificate_pem: File.read("certificate.pem")
|
|
123
|
+
)
|
|
124
|
+
```
|
|
34
125
|
|
|
35
|
-
|
|
126
|
+
### Exporting
|
|
127
|
+
```ruby
|
|
128
|
+
cert.private_key # OpenSSL::PKey::RSA object
|
|
129
|
+
cert.certificate # OpenSSL::X509::Certificate object
|
|
130
|
+
cert.private_key_pem # PEM-encoded private key
|
|
131
|
+
cert.certificate_pem # PEM-encoded certificate
|
|
132
|
+
cert.base64 # Base64-encoded certificate (for SAML metadata)
|
|
133
|
+
cert.fingerprint # SHA256 fingerprint
|
|
134
|
+
```
|
data/Rakefile
CHANGED
|
@@ -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
|
+
valid_for: DEFAULTS.valid_for,
|
|
12
|
+
audience: DEFAULTS.audience,
|
|
13
|
+
authn_context: DEFAULTS.authn_context,
|
|
14
|
+
attributes: DEFAULTS.attributes
|
|
15
|
+
)
|
|
16
|
+
@id = ID.generate
|
|
17
|
+
@session_index = ID.generate
|
|
18
|
+
@issue_instant = Time.now.utc
|
|
19
|
+
@issuer = issuer
|
|
20
|
+
@name_id = name_id
|
|
21
|
+
@name_id_format = name_id_format
|
|
22
|
+
@recipient = recipient
|
|
23
|
+
@in_response_to = in_response_to
|
|
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)
|
|
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)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def conditions
|
|
72
|
+
REXML::Element.new("saml:Conditions").tap do |c|
|
|
73
|
+
c.add_attribute("NotBefore", @issue_instant.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", @session_index)
|
|
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,68 @@
|
|
|
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
|
+
@not_after = valid_until || Time.now + (valid_for * 24 * 60 * 60)
|
|
29
|
+
@certificate = certificate || build_certificate
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def private_key_pem
|
|
33
|
+
@private_key.to_pem
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def certificate_pem
|
|
37
|
+
@certificate.to_pem
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fingerprint
|
|
41
|
+
OpenSSL::Digest::SHA256.hexdigest(@certificate.to_der)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def base64
|
|
45
|
+
Base64.strict_encode64(@certificate.to_der)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_certificate
|
|
51
|
+
OpenSSL::X509::Certificate.new.tap do |c|
|
|
52
|
+
c.public_key = @private_key.public_key
|
|
53
|
+
c.subject = build_subject
|
|
54
|
+
c.issuer = c.subject
|
|
55
|
+
c.not_before = Time.now
|
|
56
|
+
c.not_after = @not_after
|
|
57
|
+
c.sign(@private_key, OpenSSL::Digest::SHA256.new)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_subject
|
|
62
|
+
OpenSSL::X509::Name.new.tap do |name|
|
|
63
|
+
name.add_entry("CN", @common_name) if @common_name
|
|
64
|
+
name.add_entry("O", @organization) if @organization
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
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
|
data/lib/lyrebird/id.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyrebird
|
|
4
|
+
class Response
|
|
5
|
+
def initialize(
|
|
6
|
+
issuer: DEFAULTS.issuer,
|
|
7
|
+
destination: DEFAULTS.recipient,
|
|
8
|
+
in_response_to: DEFAULTS.in_response_to,
|
|
9
|
+
certificate: nil,
|
|
10
|
+
sign_assertion: false,
|
|
11
|
+
sign_response: false,
|
|
12
|
+
**assertion_options
|
|
13
|
+
)
|
|
14
|
+
@id = ID.generate
|
|
15
|
+
@issue_instant = Time.now.utc
|
|
16
|
+
@issuer = issuer
|
|
17
|
+
@destination = destination
|
|
18
|
+
@in_response_to = in_response_to
|
|
19
|
+
@certificate = certificate
|
|
20
|
+
@sign_assertion = sign_assertion
|
|
21
|
+
@sign_response = sign_response
|
|
22
|
+
|
|
23
|
+
@assertion = Assertion.new(
|
|
24
|
+
issuer: issuer,
|
|
25
|
+
in_response_to: in_response_to,
|
|
26
|
+
**assertion_options
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mimic
|
|
31
|
+
Base64.strict_encode64(document.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def document
|
|
35
|
+
REXML::Document.new.tap do |d|
|
|
36
|
+
d.add_element(root)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def root
|
|
43
|
+
REXML::Element.new("samlp:Response").tap do |r|
|
|
44
|
+
r.add_namespace("samlp", SAML_PROTOCOL_NS)
|
|
45
|
+
r.add_namespace("saml", SAML_ASSERTION_NS)
|
|
46
|
+
r.add_attribute("ID", @id)
|
|
47
|
+
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)
|
|
51
|
+
r.add_element("saml:Issuer").text = @issuer
|
|
52
|
+
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
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def status
|
|
60
|
+
REXML::Element.new("samlp:Status").tap do |s|
|
|
61
|
+
sc = s.add_element("samlp:StatusCode")
|
|
62
|
+
sc.add_attribute("Value", STATUS_SUCCESS)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyrebird
|
|
4
|
+
class Signature
|
|
5
|
+
def initialize(element, certificate:)
|
|
6
|
+
@element = element
|
|
7
|
+
@certificate = certificate
|
|
8
|
+
@element_id = @element.attributes["ID"]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def sign!
|
|
12
|
+
issuer = @element.elements["saml:Issuer"]
|
|
13
|
+
@element.insert_after(issuer, signature_element)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def signature_element
|
|
19
|
+
REXML::Element.new("ds:Signature").tap do |sig|
|
|
20
|
+
sig.add_namespace("ds", XMLDSIG_NS)
|
|
21
|
+
sig.add_element(signed_info)
|
|
22
|
+
sig.add_element(signature_value)
|
|
23
|
+
sig.add_element(key_info)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def signed_info
|
|
28
|
+
REXML::Element.new("ds:SignedInfo").tap do |si|
|
|
29
|
+
cm = si.add_element("ds:CanonicalizationMethod")
|
|
30
|
+
cm.add_attribute("Algorithm", EXC_C14N)
|
|
31
|
+
sm = si.add_element("ds:SignatureMethod")
|
|
32
|
+
sm.add_attribute("Algorithm", RSA_SHA256)
|
|
33
|
+
si.add_element(reference)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def signature_value
|
|
38
|
+
REXML::Element.new("ds:SignatureValue").tap do |sv|
|
|
39
|
+
sig = @certificate.private_key.sign("SHA256", signed_info.to_s)
|
|
40
|
+
sv.text = Base64.strict_encode64(sig)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def key_info
|
|
45
|
+
REXML::Element.new("ds:KeyInfo").tap do |ki|
|
|
46
|
+
x = ki.add_element("ds:X509Data")
|
|
47
|
+
x.add_element("ds:X509Certificate").text = @certificate.base64
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reference
|
|
52
|
+
REXML::Element.new("ds:Reference").tap do |ref|
|
|
53
|
+
ref.add_attribute("URI", "##{@element_id}")
|
|
54
|
+
ref.add_element(transforms)
|
|
55
|
+
dm = ref.add_element("ds:DigestMethod")
|
|
56
|
+
dm.add_attribute("Algorithm", SHA256_DIGEST)
|
|
57
|
+
ref.add_element("ds:DigestValue").text = compute_digest(@element)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def transforms
|
|
62
|
+
REXML::Element.new("ds:Transforms").tap do |t|
|
|
63
|
+
enveloped = t.add_element("ds:Transform")
|
|
64
|
+
enveloped.add_attribute("Algorithm", ENVELOPED_SIG)
|
|
65
|
+
c14n = t.add_element("ds:Transform")
|
|
66
|
+
c14n.add_attribute("Algorithm", EXC_C14N)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def compute_digest(element)
|
|
71
|
+
digest = OpenSSL::Digest::SHA256.digest(element.to_s)
|
|
72
|
+
Base64.strict_encode64(digest)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/lyrebird/version.rb
CHANGED
data/lib/lyrebird.rb
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "rexml"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
require_relative "lyrebird/assertion"
|
|
10
|
+
require_relative "lyrebird/certificate"
|
|
11
|
+
require_relative "lyrebird/defaults"
|
|
12
|
+
require_relative "lyrebird/id"
|
|
13
|
+
require_relative "lyrebird/namespaces"
|
|
14
|
+
require_relative "lyrebird/response"
|
|
15
|
+
require_relative "lyrebird/signature"
|
|
3
16
|
require_relative "lyrebird/version"
|
|
4
17
|
|
|
5
18
|
module Lyrebird
|
|
6
19
|
class Error < StandardError; end
|
|
7
|
-
# Your code goes here...
|
|
8
20
|
end
|
data/lyrebird.gemspec
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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.required_ruby_version = ">= 3.2.0"
|
|
12
|
+
|
|
13
|
+
spec.files = `git ls-files -z`.split("\x0")
|
|
14
|
+
spec.files.delete("Gemfile")
|
|
15
|
+
spec.files.delete(".gitignore")
|
|
16
|
+
spec.files.reject! { |f| f.start_with?("bin/") }
|
|
17
|
+
|
|
18
|
+
spec.require_paths = ["lib"]
|
|
19
|
+
|
|
20
|
+
spec.add_dependency "base64"
|
|
21
|
+
spec.add_dependency "rexml"
|
|
22
|
+
|
|
23
|
+
spec.add_development_dependency "minitest"
|
|
24
|
+
spec.add_development_dependency "rake"
|
|
25
|
+
end
|