truelayer-signing 0.2.1 → 0.2.2
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/CHANGELOG.md +4 -0
- data/Gemfile +5 -0
- data/Rakefile +2 -0
- data/lib/truelayer-signing/config.rb +6 -4
- data/lib/truelayer-signing/errors.rb +2 -0
- data/lib/truelayer-signing/jwt.rb +11 -3
- data/lib/truelayer-signing/signer.rb +21 -12
- data/lib/truelayer-signing/utils.rb +31 -15
- data/lib/truelayer-signing/verifier.rb +26 -15
- data/lib/truelayer-signing.rb +2 -0
- data/test/test-truelayer-signing.rb +29 -27
- data/truelayer-signing.gemspec +17 -6
- metadata +25 -31
- data/examples/sign-request/Gemfile +0 -4
- data/examples/sign-request/README.md +0 -27
- data/examples/sign-request/main.rb +0 -46
- data/examples/webhook-server/Gemfile +0 -5
- data/examples/webhook-server/Gemfile.lock +0 -42
- data/examples/webhook-server/README.md +0 -30
- data/examples/webhook-server/main.rb +0 -95
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fd87086c0bf22e70c924a9b3cc97e600070d0c8840b07edc56cff847177231a8
|
4
|
+
data.tar.gz: 86633894d1b455bc43e2bbc41d175deb47b5288411355d0bc5a96768625acb13
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d2698bda8922e5daec55f4e5f25721f0f498a8e0485aff3e7852b1c0a2bf39363ca5b37674f294513ac752521325aa6eaefb924fc45bf42ea44fe948061aefc7
|
7
|
+
data.tar.gz: 5494b6be736e4d17dab4bb32f5d2c53edb331a4fa5dc67c17f1eb6845ec547aec6bb9f8e10529cf349a674b1a3be985d2af9ef8837620b50a8401fc7e0f60d3a
|
data/CHANGELOG.md
CHANGED
data/Gemfile
ADDED
data/Rakefile
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module TrueLayerSigning
|
2
4
|
class Config
|
3
5
|
attr_accessor :certificate_id, :private_key
|
@@ -12,10 +14,10 @@ module TrueLayerSigning
|
|
12
14
|
|
13
15
|
# @return [TrueLayerSigning::Config]
|
14
16
|
def initialize
|
15
|
-
@algorithm = "ES512"
|
16
|
-
@certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil)
|
17
|
-
@private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub(
|
18
|
-
@version = "2"
|
17
|
+
@algorithm = "ES512"
|
18
|
+
@certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil)
|
19
|
+
@private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub("\\n", "\n")
|
20
|
+
@version = "2"
|
19
21
|
end
|
20
22
|
end
|
21
23
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# TODO: this is a custom patch of payload-related methods, from the 'jwt' gem.
|
2
4
|
# It prevents the payload from being systematically converted to and from JSON.
|
3
5
|
# To be changed in the 'jwt' gem directly, or hard-coded in this library.
|
@@ -5,18 +7,22 @@ module JWT
|
|
5
7
|
module_function
|
6
8
|
|
7
9
|
class TrueLayerEncode < Encode
|
10
|
+
private
|
11
|
+
|
8
12
|
# See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/encode.rb#L53-L55
|
9
|
-
|
13
|
+
def encode_payload
|
10
14
|
::JWT::Base64.url_encode(@payload)
|
11
15
|
end
|
12
16
|
end
|
13
17
|
|
14
18
|
class TrueLayerDecode < Decode
|
19
|
+
private
|
20
|
+
|
15
21
|
# See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb#L154-L156
|
16
|
-
|
22
|
+
def payload
|
17
23
|
@payload ||= ::JWT::Base64.url_decode(@segments[1])
|
18
24
|
rescue ::JSON::ParserError
|
19
|
-
raise JWT::DecodeError,
|
25
|
+
raise JWT::DecodeError, "Invalid segment encoding"
|
20
26
|
end
|
21
27
|
end
|
22
28
|
|
@@ -29,6 +35,7 @@ module JWT
|
|
29
35
|
).segments
|
30
36
|
end
|
31
37
|
|
38
|
+
# rubocop:disable Style/OptionalArguments, Style/OptionalBooleanParameter
|
32
39
|
def truelayer_decode(jwt, key, verify = true, options, &keyfinder)
|
33
40
|
TrueLayerDecode.new(
|
34
41
|
jwt,
|
@@ -38,4 +45,5 @@ module JWT
|
|
38
45
|
&keyfinder
|
39
46
|
).decode_segments
|
40
47
|
end
|
48
|
+
# rubocop:enable Style/OptionalArguments, Style/OptionalBooleanParameter
|
41
49
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module TrueLayerSigning
|
2
4
|
class Signer < JwsBase
|
3
5
|
attr_reader :jws_jku
|
@@ -6,13 +8,13 @@ module TrueLayerSigning
|
|
6
8
|
ensure_signer_config!
|
7
9
|
|
8
10
|
private_key = OpenSSL::PKey.read(TrueLayerSigning.private_key)
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
11
|
+
jws_header = generate_jws_header!
|
12
|
+
jwt = JWT.truelayer_encode(
|
13
|
+
build_signing_payload,
|
14
|
+
private_key,
|
15
|
+
TrueLayerSigning.algorithm,
|
16
|
+
jws_header
|
17
|
+
)
|
16
18
|
header, _, signature = jwt.split(".")
|
17
19
|
|
18
20
|
"#{header}..#{signature}"
|
@@ -24,13 +26,20 @@ module TrueLayerSigning
|
|
24
26
|
self
|
25
27
|
end
|
26
28
|
|
27
|
-
private
|
29
|
+
private
|
30
|
+
|
31
|
+
def generate_jws_header!
|
32
|
+
jws_header_args = { tl_headers: headers }
|
33
|
+
jws_header_args[:jku] = jws_jku if jws_jku
|
34
|
+
|
35
|
+
TrueLayerSigning::JwsHeader.new(jws_header_args).to_h
|
36
|
+
end
|
37
|
+
|
38
|
+
def ensure_signer_config!
|
28
39
|
raise(Error, "TRUELAYER_SIGNING_CERTIFICATE_ID missing") \
|
29
|
-
if TrueLayerSigning.certificate_id.nil? ||
|
30
|
-
TrueLayerSigning.certificate_id.empty?
|
40
|
+
if TrueLayerSigning.certificate_id.nil? || TrueLayerSigning.certificate_id.empty?
|
31
41
|
raise(Error, "TRUELAYER_SIGNING_PRIVATE_KEY missing") \
|
32
|
-
if TrueLayerSigning.private_key.nil? ||
|
33
|
-
TrueLayerSigning.private_key.empty?
|
42
|
+
if TrueLayerSigning.private_key.nil? || TrueLayerSigning.private_key.empty?
|
34
43
|
raise(Error, "Request path missing") unless path
|
35
44
|
raise(Error, "Request body missing") unless body
|
36
45
|
end
|
@@ -1,11 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module TrueLayerSigning
|
4
|
+
class Utils
|
5
|
+
def self.normalise_headers!(headers)
|
6
|
+
normalised_headers = {}
|
7
|
+
|
8
|
+
headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
|
9
|
+
|
10
|
+
normalised_headers
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
2
14
|
class JwsHeader
|
3
15
|
attr_reader :alg, :kid, :tl_version, :tl_headers, :jku
|
4
16
|
|
5
17
|
def initialize(args = {})
|
6
18
|
raise(Error, "TRUELAYER_SIGNING_CERTIFICATE_ID is missing") \
|
7
|
-
if TrueLayerSigning.certificate_id.nil? ||
|
8
|
-
TrueLayerSigning.certificate_id.empty?
|
19
|
+
if TrueLayerSigning.certificate_id.nil? || TrueLayerSigning.certificate_id.empty?
|
9
20
|
|
10
21
|
@alg = args[:alg] || TrueLayerSigning.algorithm
|
11
22
|
@kid = args[:kid] || TrueLayerSigning.certificate_id
|
@@ -15,37 +26,40 @@ module TrueLayerSigning
|
|
15
26
|
end
|
16
27
|
|
17
28
|
def to_h
|
18
|
-
hash = instance_variables.
|
29
|
+
hash = instance_variables.to_h { |var| [var[1..].to_sym, instance_variable_get(var)] }
|
19
30
|
|
20
31
|
hash.reject { |key, _value| hash[key].nil? }
|
21
32
|
end
|
22
33
|
|
23
34
|
def filter_headers(headers)
|
24
|
-
required_header_keys = tl_headers.split(",").reject
|
25
|
-
normalised_headers =
|
35
|
+
required_header_keys = tl_headers.split(",").reject(&:empty?)
|
36
|
+
normalised_headers = Utils.normalise_headers!(headers)
|
37
|
+
ordered_headers = validate_declared_headers!(required_header_keys, normalised_headers)
|
26
38
|
|
27
|
-
|
39
|
+
ordered_headers.to_h
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
28
43
|
|
29
|
-
|
44
|
+
def validate_declared_headers!(required_header_keys, normalised_headers)
|
45
|
+
required_header_keys.map do |key|
|
30
46
|
value = normalised_headers[key.downcase]
|
31
47
|
|
32
48
|
raise(Error, "Missing header declared in signature: #{key.downcase}") unless value
|
33
49
|
|
34
50
|
[key, value]
|
35
51
|
end
|
36
|
-
|
37
|
-
ordered_headers.to_h
|
38
52
|
end
|
39
53
|
|
40
|
-
|
41
|
-
tl_headers
|
54
|
+
def retrieve_headers(tl_headers)
|
55
|
+
(tl_headers.is_a?(Hash) && tl_headers.keys.join(",")) || tl_headers || ""
|
42
56
|
end
|
43
57
|
end
|
44
58
|
|
45
59
|
class JwsBase
|
46
60
|
attr_reader :method, :path, :headers, :body
|
47
61
|
|
48
|
-
def initialize(
|
62
|
+
def initialize(_args = {})
|
49
63
|
@method = "POST"
|
50
64
|
@headers = {}
|
51
65
|
end
|
@@ -82,15 +96,17 @@ module TrueLayerSigning
|
|
82
96
|
self
|
83
97
|
end
|
84
98
|
|
85
|
-
private
|
99
|
+
private
|
100
|
+
|
101
|
+
def build_signing_payload(custom_headers = nil)
|
86
102
|
parts = []
|
87
103
|
parts.push("#{method.upcase} #{path}")
|
88
|
-
parts.push(custom_headers && format_headers(custom_headers) || format_headers(headers))
|
104
|
+
parts.push((custom_headers && format_headers(custom_headers)) || format_headers(headers))
|
89
105
|
parts.push(body)
|
90
106
|
parts.reject { |elem| elem.nil? || elem.empty? }.join("\n")
|
91
107
|
end
|
92
108
|
|
93
|
-
|
109
|
+
def format_headers(headers)
|
94
110
|
headers.map { |key, value| "#{key}: #{value}" }.join("\n")
|
95
111
|
end
|
96
112
|
end
|
@@ -1,6 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module TrueLayerSigning
|
2
4
|
class Verifier < JwsBase
|
3
|
-
EXPECTED_EC_KEY_COORDS_LENGTH = 66
|
5
|
+
EXPECTED_EC_KEY_COORDS_LENGTH = 66
|
4
6
|
|
5
7
|
attr_reader :required_headers, :key_type, :key_value
|
6
8
|
|
@@ -16,15 +18,12 @@ module TrueLayerSigning
|
|
16
18
|
|
17
19
|
jws_header, jws_header_b64, signature_b64 = self.class.parse_tl_signature(tl_signature)
|
18
20
|
|
19
|
-
|
21
|
+
validate_algorithm!(jws_header.alg)
|
20
22
|
|
21
23
|
ordered_headers = jws_header.filter_headers(headers)
|
22
|
-
normalised_headers =
|
23
|
-
|
24
|
-
ordered_headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
|
24
|
+
normalised_headers = Utils.normalise_headers!(ordered_headers)
|
25
25
|
|
26
|
-
|
27
|
-
required_headers.any? { |key| !normalised_headers.has_key?(key.downcase) }
|
26
|
+
validate_required_headers!(normalised_headers)
|
28
27
|
|
29
28
|
verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
|
30
29
|
end
|
@@ -58,13 +57,15 @@ module TrueLayerSigning
|
|
58
57
|
[jws_header, jws_header_b64, signature_b64]
|
59
58
|
end
|
60
59
|
|
61
|
-
private
|
60
|
+
private
|
61
|
+
|
62
|
+
def verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
|
62
63
|
full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
63
64
|
|
64
65
|
begin
|
65
66
|
verify_signature(jws_header, full_signature)
|
66
67
|
rescue JWT::VerificationError
|
67
|
-
@path = path.end_with?("/")
|
68
|
+
@path = path.end_with?("/") ? path[0...-1] : "#{path}/"
|
68
69
|
full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
69
70
|
|
70
71
|
begin
|
@@ -75,13 +76,13 @@ module TrueLayerSigning
|
|
75
76
|
end
|
76
77
|
end
|
77
78
|
|
78
|
-
|
79
|
+
def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
79
80
|
payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers), padding: false)
|
80
81
|
|
81
82
|
[jws_header_b64, payload_b64, signature_b64].join(".")
|
82
83
|
end
|
83
84
|
|
84
|
-
|
85
|
+
def verify_signature(jws_header, full_signature)
|
85
86
|
case key_type
|
86
87
|
when :pem
|
87
88
|
public_key = OpenSSL::PKey.read(key_value)
|
@@ -98,7 +99,7 @@ module TrueLayerSigning
|
|
98
99
|
JWT.truelayer_decode(full_signature, public_key, jwt_options)
|
99
100
|
end
|
100
101
|
|
101
|
-
|
102
|
+
def retrieve_public_key(key_type, key_value, jws_header)
|
102
103
|
case key_type
|
103
104
|
when :pem
|
104
105
|
OpenSSL::PKey.read(key_value)
|
@@ -116,20 +117,30 @@ module TrueLayerSigning
|
|
116
117
|
end
|
117
118
|
end
|
118
119
|
|
119
|
-
|
120
|
+
def apply_zero_padding_as_needed(jwk)
|
120
121
|
valid_jwk = jwk.clone
|
121
122
|
|
122
123
|
%i(x y).each do |elem|
|
123
124
|
coords = Base64.urlsafe_decode64(valid_jwk[elem])
|
124
125
|
diff = EXPECTED_EC_KEY_COORDS_LENGTH - coords.length
|
125
126
|
|
126
|
-
valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff
|
127
|
+
valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff.positive?
|
127
128
|
end
|
128
129
|
|
129
130
|
valid_jwk
|
130
131
|
end
|
131
132
|
|
132
|
-
|
133
|
+
def validate_required_headers!(headers)
|
134
|
+
raise(Error, "Signature missing required header(s)") if required_headers&.any? do |key|
|
135
|
+
!headers.key?(key.downcase)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def validate_algorithm!(algorithm)
|
140
|
+
raise(Error, "Unexpected `alg` header value") if algorithm != TrueLayerSigning.algorithm
|
141
|
+
end
|
142
|
+
|
143
|
+
def ensure_verifier_config!
|
133
144
|
raise(Error, "Key value missing") unless key_value
|
134
145
|
end
|
135
146
|
end
|
data/lib/truelayer-signing.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "minitest/autorun"
|
2
4
|
require "truelayer-signing"
|
3
5
|
|
@@ -5,12 +7,12 @@ def read_file(path)
|
|
5
7
|
File.read(File.expand_path(path, File.dirname(__FILE__)))
|
6
8
|
end
|
7
9
|
|
8
|
-
CERTIFICATE_ID = "45fc75cf-5649-4134-84b3-192c2c78e990"
|
9
|
-
PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem")
|
10
|
-
PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem")
|
10
|
+
CERTIFICATE_ID = "45fc75cf-5649-4134-84b3-192c2c78e990"
|
11
|
+
PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem")
|
12
|
+
PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem")
|
11
13
|
|
12
|
-
TrueLayerSigning.certificate_id = CERTIFICATE_ID
|
13
|
-
TrueLayerSigning.private_key = PRIVATE_KEY
|
14
|
+
TrueLayerSigning.certificate_id = CERTIFICATE_ID
|
15
|
+
TrueLayerSigning.private_key = PRIVATE_KEY
|
14
16
|
|
15
17
|
class TrueLayerSigningTest < Minitest::Test
|
16
18
|
def test_full_request_signature_should_succeed
|
@@ -35,7 +37,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
35
37
|
.verify(tl_signature)
|
36
38
|
|
37
39
|
refute(result.first.include?("\nX-Whatever: aoitbeh\n"))
|
38
|
-
assert(result.first.include?("\nIdempotency-Key:
|
40
|
+
assert(result.first.include?("\nIdempotency-Key: #{idempotency_key}\n"))
|
39
41
|
assert(result.first
|
40
42
|
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
41
43
|
end
|
@@ -82,11 +84,11 @@ class TrueLayerSigningTest < Minitest::Test
|
|
82
84
|
def test_mismatched_signature_with_attached_valid_body_should_fail
|
83
85
|
# Signature for `/bar` but with a valid jws-body pre-attached.
|
84
86
|
# If we run a simple jws verify on this unchanged, it'll work!
|
85
|
-
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND"
|
86
|
-
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV"
|
87
|
-
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD"
|
88
|
-
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC"
|
89
|
-
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB"
|
87
|
+
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" \
|
88
|
+
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" \
|
89
|
+
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" \
|
90
|
+
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" \
|
91
|
+
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" \
|
90
92
|
"d2d3D17Wd9UA"
|
91
93
|
|
92
94
|
verifier = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
@@ -101,11 +103,11 @@ class TrueLayerSigningTest < Minitest::Test
|
|
101
103
|
def test_mismatched_signature_with_attached_valid_body_and_trailing_dots_should_fail
|
102
104
|
# Signature for `/bar` but with a valid jws-body pre-attached.
|
103
105
|
# If we run a simple jws verify on this unchanged, it'll work!
|
104
|
-
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND"
|
105
|
-
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV"
|
106
|
-
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD"
|
107
|
-
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC"
|
108
|
-
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB"
|
106
|
+
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" \
|
107
|
+
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" \
|
108
|
+
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" \
|
109
|
+
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" \
|
110
|
+
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" \
|
109
111
|
"d2d3D17Wd9UA...."
|
110
112
|
|
111
113
|
verifier = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
@@ -132,7 +134,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
132
134
|
.verify(tl_signature)
|
133
135
|
|
134
136
|
refute(result.first.include?("\nX-Whatever-2: t2345d\n"))
|
135
|
-
assert(result.first.include?("\nIdempotency-Key:
|
137
|
+
assert(result.first.include?("\nIdempotency-Key: #{idempotency_key}\n"))
|
136
138
|
assert(result.first
|
137
139
|
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
138
140
|
end
|
@@ -482,34 +484,34 @@ class TrueLayerSigningTest < Minitest::Test
|
|
482
484
|
idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"
|
483
485
|
path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
|
484
486
|
|
485
|
-
|
487
|
+
tl_signature_a = TrueLayerSigning.sign_with_pem
|
486
488
|
.set_path(path)
|
487
489
|
.add_header("Idempotency-Key", idempotency_key)
|
488
490
|
.set_body(body)
|
489
491
|
.sign
|
490
492
|
|
491
|
-
|
493
|
+
jws_header_a = TrueLayerSigning.extract_jws_header(tl_signature_a)
|
492
494
|
|
493
|
-
assert_nil(
|
495
|
+
assert_nil(jws_header_a.jku)
|
494
496
|
|
495
|
-
|
497
|
+
tl_signature_b = TrueLayerSigning.sign_with_pem
|
496
498
|
.set_path(path)
|
497
499
|
.add_header("Idempotency-Key", idempotency_key)
|
498
500
|
.set_body(body)
|
499
501
|
.set_jku("https://webhooks.truelayer.com/.well-known/jwks")
|
500
502
|
.sign
|
501
503
|
|
502
|
-
|
504
|
+
jws_header_b = TrueLayerSigning.extract_jws_header(tl_signature_b)
|
503
505
|
|
504
|
-
assert_equal("https://webhooks.truelayer.com/.well-known/jwks",
|
506
|
+
assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header_b.jku)
|
505
507
|
end
|
506
508
|
|
507
509
|
# TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
|
508
510
|
def test_jwt_encode_and_decode_should_succeed
|
509
511
|
payload_object = { currency: "GBP", max_amount_in_minor: 50_000_00 }
|
510
|
-
token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW"
|
512
|
+
token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" \
|
511
513
|
"91bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
|
512
|
-
token_when_json = "eyJhbGciOiJIUzI1NiJ9.IntcImN1cnJlbmN5XCI6XCJHQlBcIixcIm1h"
|
514
|
+
token_when_json = "eyJhbGciOiJIUzI1NiJ9.IntcImN1cnJlbmN5XCI6XCJHQlBcIixcIm1h" \
|
513
515
|
"eF9hbW91bnRfaW5fbWlub3JcIjo1MDAwMDAwfSI.rvCcgu-JevsNxbjUwJiFOuTd0hzVKvPK5RvGmaoDc7E"
|
514
516
|
|
515
517
|
# succeeds with a hash object
|
@@ -530,7 +532,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
530
532
|
# TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
|
531
533
|
def test_jwt_truelayer_encode_and_decode_when_given_json_should_succeed
|
532
534
|
payload_json = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
|
533
|
-
token_when_json = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW9"
|
535
|
+
token_when_json = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW9" \
|
534
536
|
"1bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
|
535
537
|
|
536
538
|
assert_equal(token_when_json, JWT.truelayer_encode(payload_json, "12345", "HS256", {}))
|
@@ -549,7 +551,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
549
551
|
|
550
552
|
# TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
|
551
553
|
def test_jwt_truelayer_decode_when_given_a_hash_should_succeed
|
552
|
-
token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW"
|
554
|
+
token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" \
|
553
555
|
"91bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
|
554
556
|
|
555
557
|
assert_equal(
|
data/truelayer-signing.gemspec
CHANGED
@@ -1,8 +1,10 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "lib"))
|
2
4
|
|
3
5
|
Gem::Specification.new do |s|
|
4
6
|
s.name = "truelayer-signing"
|
5
|
-
s.version = "0.2.
|
7
|
+
s.version = "0.2.2"
|
6
8
|
s.summary = "Ruby gem to produce and verify TrueLayer API requests signatures"
|
7
9
|
s.description = "TrueLayer provides instant access to open banking to " \
|
8
10
|
"easily integrate next-generation payments and financial data into any app." \
|
@@ -14,12 +16,21 @@ Gem::Specification.new do |s|
|
|
14
16
|
|
15
17
|
s.metadata = {
|
16
18
|
"bug_tracker_uri" => "https://github.com/TrueLayer/truelayer-signing/issues",
|
17
|
-
"changelog_uri" => "https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md"
|
19
|
+
"changelog_uri" => "https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md"
|
18
20
|
}
|
19
21
|
|
20
|
-
s.files = Dir[
|
21
|
-
|
22
|
+
s.files = Dir[
|
23
|
+
"CHANGELOG.md",
|
24
|
+
"Gemfile",
|
25
|
+
"LICENSE*",
|
26
|
+
"README.md",
|
27
|
+
"Rakefile",
|
28
|
+
"lib/**/*.*",
|
29
|
+
"test/**/*.*",
|
30
|
+
"truelayer-signing.gemspec",
|
31
|
+
]
|
32
|
+
s.require_path = "lib"
|
22
33
|
|
23
34
|
s.required_ruby_version = ">= 2.7"
|
24
|
-
s.add_runtime_dependency("jwt",
|
35
|
+
s.add_runtime_dependency("jwt", "~> 2.7")
|
25
36
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: truelayer-signing
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kevin Plattret
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-07
|
11
|
+
date: 2023-12-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|
@@ -32,31 +32,25 @@ executables: []
|
|
32
32
|
extensions: []
|
33
33
|
extra_rdoc_files: []
|
34
34
|
files:
|
35
|
-
-
|
36
|
-
-
|
37
|
-
-
|
38
|
-
-
|
39
|
-
-
|
40
|
-
-
|
41
|
-
-
|
42
|
-
-
|
43
|
-
-
|
44
|
-
-
|
45
|
-
-
|
46
|
-
-
|
47
|
-
-
|
48
|
-
-
|
49
|
-
-
|
50
|
-
-
|
51
|
-
-
|
52
|
-
-
|
53
|
-
-
|
54
|
-
- "./test/resources/failed-payment-expired-test-payload.json"
|
55
|
-
- "./test/resources/missing-zero-padding-test-jwks.json"
|
56
|
-
- "./test/resources/missing-zero-padding-test-payload.json"
|
57
|
-
- "./test/resources/missing-zero-padding-test-signature.txt"
|
58
|
-
- "./test/test-truelayer-signing.rb"
|
59
|
-
- "./truelayer-signing.gemspec"
|
35
|
+
- CHANGELOG.md
|
36
|
+
- Gemfile
|
37
|
+
- LICENSE-APACHE
|
38
|
+
- LICENSE-MIT
|
39
|
+
- README.md
|
40
|
+
- Rakefile
|
41
|
+
- lib/truelayer-signing.rb
|
42
|
+
- lib/truelayer-signing/config.rb
|
43
|
+
- lib/truelayer-signing/errors.rb
|
44
|
+
- lib/truelayer-signing/jwt.rb
|
45
|
+
- lib/truelayer-signing/signer.rb
|
46
|
+
- lib/truelayer-signing/utils.rb
|
47
|
+
- lib/truelayer-signing/verifier.rb
|
48
|
+
- test/resources/failed-payment-expired-test-payload.json
|
49
|
+
- test/resources/missing-zero-padding-test-jwks.json
|
50
|
+
- test/resources/missing-zero-padding-test-payload.json
|
51
|
+
- test/resources/missing-zero-padding-test-signature.txt
|
52
|
+
- test/test-truelayer-signing.rb
|
53
|
+
- truelayer-signing.gemspec
|
60
54
|
homepage: https://github.com/TrueLayer/truelayer-signing/tree/main/ruby
|
61
55
|
licenses:
|
62
56
|
- Apache-2.0
|
@@ -64,7 +58,7 @@ licenses:
|
|
64
58
|
metadata:
|
65
59
|
bug_tracker_uri: https://github.com/TrueLayer/truelayer-signing/issues
|
66
60
|
changelog_uri: https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md
|
67
|
-
post_install_message:
|
61
|
+
post_install_message:
|
68
62
|
rdoc_options: []
|
69
63
|
require_paths:
|
70
64
|
- lib
|
@@ -79,8 +73,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
73
|
- !ruby/object:Gem::Version
|
80
74
|
version: '0'
|
81
75
|
requirements: []
|
82
|
-
rubygems_version: 3.4.
|
83
|
-
signing_key:
|
76
|
+
rubygems_version: 3.4.10
|
77
|
+
signing_key:
|
84
78
|
specification_version: 4
|
85
79
|
summary: Ruby gem to produce and verify TrueLayer API requests signatures
|
86
80
|
test_files: []
|
@@ -1,27 +0,0 @@
|
|
1
|
-
# Ruby request signature example
|
2
|
-
|
3
|
-
Sends a signed request to `https://api.truelayer-sandbox.com/test-signature`.
|
4
|
-
|
5
|
-
## Run
|
6
|
-
|
7
|
-
Set the following environment variables:
|
8
|
-
|
9
|
-
* `TRUELAYER_SIGNING_ACCESS_TOKEN` – a valid JWT access token for the `payments` scope (see our
|
10
|
-
[docs](https://docs.truelayer.com/docs/retrieve-a-token-in-your-server)).
|
11
|
-
* `TRUELAYER_SIGNING_CERTIFICATE_ID` – the certificate/key UUID associated with your public key
|
12
|
-
uploaded at [console.truelayer.com](https://console.truelayer.com).
|
13
|
-
* `TRUELAYER_SIGNING_PRIVATE_KEY` – the private key PEM string that matches the certificate ID of the
|
14
|
-
uploaded public key. Should have the same format as [this example private
|
15
|
-
key](https://github.com/TrueLayer/truelayer-signing/blob/main/test-resources/ec512-private.pem).
|
16
|
-
|
17
|
-
Install the required dependencies:
|
18
|
-
|
19
|
-
```sh
|
20
|
-
$ bundle
|
21
|
-
```
|
22
|
-
|
23
|
-
Execute the request-signing example script:
|
24
|
-
|
25
|
-
```sh
|
26
|
-
$ ruby main.rb
|
27
|
-
```
|
@@ -1,46 +0,0 @@
|
|
1
|
-
require "http"
|
2
|
-
require "securerandom"
|
3
|
-
require "truelayer-signing"
|
4
|
-
|
5
|
-
class TrueLayerSigningExamples
|
6
|
-
# Set required environment variables
|
7
|
-
TRUELAYER_SIGNING_ACCESS_TOKEN = ENV.fetch("TRUELAYER_SIGNING_ACCESS_TOKEN", nil).freeze
|
8
|
-
TRUELAYER_SIGNING_BASE_URL = "https://api.truelayer-sandbox.com".freeze
|
9
|
-
|
10
|
-
raise(StandardError, "TRUELAYER_SIGNING_ACCESS_TOKEN is missing") \
|
11
|
-
if TRUELAYER_SIGNING_ACCESS_TOKEN.nil? || TRUELAYER_SIGNING_ACCESS_TOKEN.empty?
|
12
|
-
|
13
|
-
class << self
|
14
|
-
def test_signature_endpoint
|
15
|
-
url = "#{TRUELAYER_SIGNING_BASE_URL}/test-signature"
|
16
|
-
idempotency_key = SecureRandom.uuid
|
17
|
-
|
18
|
-
# A random body string is enough for this request as the `/test-signature` endpoint does not
|
19
|
-
# require any schema, it simply checks the signature is valid against what's received.
|
20
|
-
body = "body-#{SecureRandom.uuid}"
|
21
|
-
|
22
|
-
# Generate a `Tl-Signature`
|
23
|
-
signature = TrueLayerSigning.sign_with_pem
|
24
|
-
.set_method("POST")
|
25
|
-
.set_path("/test-signature")
|
26
|
-
# Optional: `/test-signature` does not require any headers, but we may sign some anyway.
|
27
|
-
# All signed headers *must* be included unmodified in the request.
|
28
|
-
.add_header("Idempotency-Key", idempotency_key)
|
29
|
-
.add_header("X-Bar-Header", "abc123")
|
30
|
-
.set_body(body)
|
31
|
-
.sign
|
32
|
-
|
33
|
-
response = HTTP.auth("Bearer #{TRUELAYER_SIGNING_ACCESS_TOKEN}")
|
34
|
-
.headers(idempotency_key: idempotency_key)
|
35
|
-
.headers(x_bar_header: "abc123")
|
36
|
-
.headers(tl_signature: signature)
|
37
|
-
.post(url, body: body)
|
38
|
-
|
39
|
-
return puts "✓ Signature is valid" if response.status.success?
|
40
|
-
|
41
|
-
puts JSON.pretty_generate(JSON.parse(response.to_s))
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
TrueLayerSigningExamples.test_signature_endpoint
|
@@ -1,42 +0,0 @@
|
|
1
|
-
GEM
|
2
|
-
remote: https://rubygems.org/
|
3
|
-
specs:
|
4
|
-
addressable (2.8.4)
|
5
|
-
public_suffix (>= 2.0.2, < 6.0)
|
6
|
-
domain_name (0.5.20190701)
|
7
|
-
unf (>= 0.0.5, < 1.0.0)
|
8
|
-
ffi (1.15.5)
|
9
|
-
ffi-compiler (1.0.1)
|
10
|
-
ffi (>= 1.0.0)
|
11
|
-
rake
|
12
|
-
http (5.1.1)
|
13
|
-
addressable (~> 2.8)
|
14
|
-
http-cookie (~> 1.0)
|
15
|
-
http-form_data (~> 2.2)
|
16
|
-
llhttp-ffi (~> 0.4.0)
|
17
|
-
http-cookie (1.0.5)
|
18
|
-
domain_name (~> 0.5)
|
19
|
-
http-form_data (2.3.0)
|
20
|
-
jwt (2.7.0)
|
21
|
-
llhttp-ffi (0.4.0)
|
22
|
-
ffi-compiler (~> 1.0)
|
23
|
-
rake (~> 13.0)
|
24
|
-
public_suffix (5.0.1)
|
25
|
-
rake (13.0.6)
|
26
|
-
truelayer-signing (0.2.0)
|
27
|
-
jwt (~> 2.7)
|
28
|
-
unf (0.1.4)
|
29
|
-
unf_ext
|
30
|
-
unf_ext (0.0.8.2)
|
31
|
-
webrick (1.8.1)
|
32
|
-
|
33
|
-
PLATFORMS
|
34
|
-
arm64-darwin-21
|
35
|
-
|
36
|
-
DEPENDENCIES
|
37
|
-
http
|
38
|
-
truelayer-signing
|
39
|
-
webrick
|
40
|
-
|
41
|
-
BUNDLED WITH
|
42
|
-
2.4.13
|
@@ -1,30 +0,0 @@
|
|
1
|
-
# Ruby webhook server example
|
2
|
-
|
3
|
-
An HTTP server that can receive and verify signed TrueLayer webhooks.
|
4
|
-
|
5
|
-
## Run
|
6
|
-
|
7
|
-
Install the required dependencies:
|
8
|
-
|
9
|
-
```sh
|
10
|
-
$ bundle
|
11
|
-
```
|
12
|
-
|
13
|
-
Run the server locally:
|
14
|
-
|
15
|
-
```sh
|
16
|
-
$ ruby main.rb
|
17
|
-
```
|
18
|
-
|
19
|
-
Send a valid webhook that was signed for the path `/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b`:
|
20
|
-
|
21
|
-
```sh
|
22
|
-
curl -iX POST -H "Content-Type: application/json" \
|
23
|
-
-H "X-Tl-Webhook-Timestamp: 2022-03-11T14:00:33Z" \
|
24
|
-
-H "Tl-Signature: eyJhbGciOiJFUzUxMiIsImtpZCI6IjFmYzBlNTlmLWIzMzUtNDdjYS05OWE5LTczNzQ5NTc1NmE1OCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGVhZGVycyI6IngtdGwtd2ViaG9vay10aW1lc3RhbXAiLCJqa3UiOiJodHRwczovL3dlYmhvb2tzLnRydWVsYXllci5jb20vLndlbGwta25vd24vandrcyJ9..AE_QsBRhnsMkcRzd42wvY1e2HruUhkOgjuZKktGH_WmbD7rBzoaEHUuF36IxyyvCbLajd3MBExNmzjbrOQsGaspwAI5DcGVMFLKUhB7ZzUlTP9up3eNUrdwWyyfBWDQb-qmEuLnrhFDJvgCXEqlV5OLrt-O7LaRAJ4f9KHsZLQ_j2vPC" \
|
25
|
-
-d "{\"event_type\":\"payout_settled\",\"event_schema_version\":1,\"event_id\":\"8fb9fb4e-bb2b-400b-af64-59e5dde74bad\",\"event_body\":{\"transaction_id\":\"c34c8721-66a9-49f6-a229-284efcf88a02\",\"settled_at\":\"2022-03-11T14:00:32.933000Z\"}}" \
|
26
|
-
http://localhost:4567/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b
|
27
|
-
```
|
28
|
-
|
29
|
-
Modifying the `X-Tl-Webhook-Timestamp` header, the body or the path of the request will cause the
|
30
|
-
above signature to be invalid.
|
@@ -1,95 +0,0 @@
|
|
1
|
-
require "http"
|
2
|
-
require "truelayer-signing"
|
3
|
-
require "webrick"
|
4
|
-
|
5
|
-
class TrueLayerSigningExamples
|
6
|
-
# Note: the webhook path can be whatever is configured for your application.
|
7
|
-
# Here a unique path is used, matching the example signature in the README.
|
8
|
-
WEBHOOK_PATH = "/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b".freeze
|
9
|
-
|
10
|
-
class << self
|
11
|
-
def run_webhook_server
|
12
|
-
server = WEBrick::HTTPServer.new(Port: 4567)
|
13
|
-
|
14
|
-
puts "Server running at http://localhost:4567"
|
15
|
-
|
16
|
-
server.mount_proc('/') do |req, res|
|
17
|
-
request = parse_request(req)
|
18
|
-
status, body = handle_request.call(request)
|
19
|
-
headers = { "Content-Type" => "text/plain" }
|
20
|
-
|
21
|
-
send_response(res, status, headers, body)
|
22
|
-
rescue => error
|
23
|
-
puts error
|
24
|
-
end
|
25
|
-
|
26
|
-
server.start
|
27
|
-
ensure
|
28
|
-
server.shutdown
|
29
|
-
end
|
30
|
-
|
31
|
-
private def parse_request(request)
|
32
|
-
{
|
33
|
-
method: request.request_method,
|
34
|
-
path: request.path,
|
35
|
-
headers: headers_to_hash(request.header),
|
36
|
-
body: request.body
|
37
|
-
}
|
38
|
-
end
|
39
|
-
|
40
|
-
private def handle_request
|
41
|
-
Proc.new do |request|
|
42
|
-
if request[:method] == "POST" && request[:path] == WEBHOOK_PATH
|
43
|
-
verify_webhook(request[:path], request[:headers], request[:body])
|
44
|
-
else
|
45
|
-
["403", "Forbidden"]
|
46
|
-
end
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
private def verify_webhook(path, headers, body)
|
51
|
-
tl_signature = headers["tl-signature"]
|
52
|
-
|
53
|
-
return ["400", "Bad Request – Header `Tl-Signature` missing"] unless tl_signature
|
54
|
-
|
55
|
-
jku = TrueLayerSigning.extract_jws_header(tl_signature).jku
|
56
|
-
|
57
|
-
return ["400", "Bad Request – Signature missing `jku`"] unless jku
|
58
|
-
return ["401", "Unauthorized – Unpermitted `jku`"] \
|
59
|
-
unless jku == "https://webhooks.truelayer.com/.well-known/jwks" ||
|
60
|
-
jku == "https://webhooks.truelayer-sandbox.com/.well-known/jwks"
|
61
|
-
|
62
|
-
jwks = HTTP.get(jku)
|
63
|
-
|
64
|
-
return ["401", "Unauthorized – Unavailable `jwks` resource"] unless jwks.status.success?
|
65
|
-
|
66
|
-
begin
|
67
|
-
TrueLayerSigning.verify_with_jwks(jwks.to_s)
|
68
|
-
.set_method(:post)
|
69
|
-
.set_path(path)
|
70
|
-
.set_headers(headers)
|
71
|
-
.set_body(body)
|
72
|
-
.verify(tl_signature)
|
73
|
-
|
74
|
-
["202", "Accepted"]
|
75
|
-
rescue TrueLayerSigning::Error => error
|
76
|
-
puts error
|
77
|
-
|
78
|
-
["401", "Unauthorized"]
|
79
|
-
end
|
80
|
-
end
|
81
|
-
|
82
|
-
private def headers_to_hash(headers)
|
83
|
-
headers.transform_keys { |key| key.to_s.strip.downcase }.transform_values(&:first)
|
84
|
-
end
|
85
|
-
|
86
|
-
private def send_response(response, status, headers, body)
|
87
|
-
response.status = status
|
88
|
-
response.header.merge!(headers)
|
89
|
-
response.body = body
|
90
|
-
response
|
91
|
-
end
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
TrueLayerSigningExamples.run_webhook_server
|