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 +4 -4
- data/.github/workflows/ci.yml +25 -0
- data/.github/workflows/publish.yml +20 -0
- data/README.md +148 -17
- data/Rakefile +8 -1
- data/lib/lyrebird/assertion.rb +104 -0
- data/lib/lyrebird/certificate.rb +71 -0
- data/lib/lyrebird/defaults.rb +41 -0
- data/lib/lyrebird/encryption.rb +75 -0
- data/lib/lyrebird/id.rb +9 -0
- data/lib/lyrebird/namespaces.rb +17 -0
- data/lib/lyrebird/response.rb +79 -0
- data/lib/lyrebird/signature.rb +75 -0
- data/lib/lyrebird/version.rb +1 -1
- data/lib/lyrebird.rb +15 -1
- data/lyrebird.gemspec +26 -0
- data/test/lyrebird/assertion_test.rb +329 -0
- data/test/lyrebird/certificate_test.rb +87 -0
- data/test/lyrebird/defaults_test.rb +11 -0
- data/test/lyrebird/encryption_test.rb +126 -0
- data/test/lyrebird/id_test.rb +11 -0
- data/test/lyrebird/response_test.rb +240 -0
- data/test/lyrebird/signature_test.rb +137 -0
- data/test/test_helper.rb +4 -0
- metadata +103 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b18c9229d638025f9882d18957a043c890e128ae5cbf9541d97f0cce078ce8e1
|
|
4
|
+
data.tar.gz: 28ce7ba82169a7fa5b1ac90299eb09af37781adefc74522a6ee43e16f822f740
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.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
|
-
|
|
65
|
+
r.attributes do |a|
|
|
66
|
+
a.email = "user@example.com"
|
|
67
|
+
a.groups = ["admin", "users"]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
10
71
|
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
132
|
+
## Certificate
|
|
133
|
+
Generates and manages X.509 certificates for signing SAML responses.
|
|
28
134
|
|
|
29
|
-
|
|
135
|
+
### Generating a new certificate
|
|
136
|
+
```ruby
|
|
137
|
+
# With defaults
|
|
138
|
+
cert = Lyrebird::Certificate.generate
|
|
30
139
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
data/lib/lyrebird/id.rb
ADDED
|
@@ -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
|