truelayer-signing 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7675d871fb8de41624e5f92fb6b0252fac90900f2f52c6bd9b182ea0f80cde44
4
- data.tar.gz: 2345bf7cfc1619ef8c5e1bb20ed56f966ec9a71263798bde713aca0ba92b0811
3
+ metadata.gz: fd87086c0bf22e70c924a9b3cc97e600070d0c8840b07edc56cff847177231a8
4
+ data.tar.gz: 86633894d1b455bc43e2bbc41d175deb47b5288411355d0bc5a96768625acb13
5
5
  SHA512:
6
- metadata.gz: df411a9c02a97ab165f5a47063ec0ca3fae67fe437f96af20b1f71eab4f88105bd8259a3c9685056c91210c3dca0134721f5c3b94e9cc39691756f6b64c17279
7
- data.tar.gz: a39b865f5c4a1a3226cc8071f3c5971ff8c32c536d4b7b88ddab8cf7ffcc71fa2e36c3d3409b5fa7f3d25647f8ea8fb202ef6f74ac51661e85c2b089f6a4e2c4
6
+ metadata.gz: d2698bda8922e5daec55f4e5f25721f0f498a8e0485aff3e7852b1c0a2bf39363ca5b37674f294513ac752521325aa6eaefb924fc45bf42ea44fe948061aefc7
7
+ data.tar.gz: 5494b6be736e4d17dab4bb32f5d2c53edb331a4fa5dc67c17f1eb6845ec547aec6bb9f8e10529cf349a674b1a3be985d2af9ef8837620b50a8401fc7e0f60d3a
data/CHANGELOG.md CHANGED
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  - ...
11
11
 
12
+ ## [0.2.2] – 2023-12-06
13
+
14
+ - Add code linter
15
+
16
+ ## [0.2.1] – 2023-07-14
17
+
18
+ - Disable expiration verification
19
+ - Add missing header discovery
20
+
12
21
  ## [0.2.0] – 2023-06-13
13
22
 
14
23
  - Add support for signature verification using a JWKS: `TrueLayerSigning.verify_with_jwks(jwks)`
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rubocop", "~> 1.57", require: false
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rake/testtask"
2
4
 
3
5
  Rake::TestTask.new do |t|
@@ -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".freeze
16
- @certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil).freeze
17
- @private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub(/\\n/, "\n").freeze
18
- @version = "2".freeze
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
  module TrueLayerSigning
2
4
  class Error < StandardError; end
3
5
  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
- private def encode_payload
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
- private def payload
22
+ def payload
17
23
  @payload ||= ::JWT::Base64.url_decode(@segments[1])
18
24
  rescue ::JSON::ParserError
19
- raise JWT::DecodeError, 'Invalid segment encoding'
25
+ raise JWT::DecodeError, "Invalid segment encoding"
20
26
  end
21
27
  end
22
28
 
@@ -29,7 +35,8 @@ module JWT
29
35
  ).segments
30
36
  end
31
37
 
32
- def truelayer_decode(jwt, key, verify, options, &keyfinder)
38
+ # rubocop:disable Style/OptionalArguments, Style/OptionalBooleanParameter
39
+ def truelayer_decode(jwt, key, verify = true, options, &keyfinder)
33
40
  TrueLayerDecode.new(
34
41
  jwt,
35
42
  key,
@@ -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,11 +8,13 @@ module TrueLayerSigning
6
8
  ensure_signer_config!
7
9
 
8
10
  private_key = OpenSSL::PKey.read(TrueLayerSigning.private_key)
9
- jws_header_args = { tl_headers: headers }
10
- jws_header_args[:jku] = jws_jku if jws_jku
11
- jws_header = TrueLayerSigning::JwsHeader.new(jws_header_args).to_h
12
- jwt = JWT.truelayer_encode(build_signing_payload, private_key, TrueLayerSigning.algorithm,
13
- jws_header)
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
+ )
14
18
  header, _, signature = jwt.split(".")
15
19
 
16
20
  "#{header}..#{signature}"
@@ -18,16 +22,24 @@ module TrueLayerSigning
18
22
 
19
23
  def set_jku(jku)
20
24
  @jws_jku = jku
25
+
21
26
  self
22
27
  end
23
28
 
24
- private def ensure_signer_config!
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!
25
39
  raise(Error, "TRUELAYER_SIGNING_CERTIFICATE_ID missing") \
26
- if TrueLayerSigning.certificate_id.nil? ||
27
- TrueLayerSigning.certificate_id.empty?
40
+ if TrueLayerSigning.certificate_id.nil? || TrueLayerSigning.certificate_id.empty?
28
41
  raise(Error, "TRUELAYER_SIGNING_PRIVATE_KEY missing") \
29
- if TrueLayerSigning.private_key.nil? ||
30
- TrueLayerSigning.private_key.empty?
42
+ if TrueLayerSigning.private_key.nil? || TrueLayerSigning.private_key.empty?
31
43
  raise(Error, "Request path missing") unless path
32
44
  raise(Error, "Request body missing") unless body
33
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,41 +26,47 @@ module TrueLayerSigning
15
26
  end
16
27
 
17
28
  def to_h
18
- hash = instance_variables.map { |var| [var[1..-1].to_sym, instance_variable_get(var)] }.to_h
29
+ hash = instance_variables.to_h { |var| [var[1..].to_sym, instance_variable_get(var)] }
30
+
19
31
  hash.reject { |key, _value| hash[key].nil? }
20
32
  end
21
33
 
22
34
  def filter_headers(headers)
23
- required_header_keys = tl_headers.split(",").reject { |key| key.empty? }
24
- normalised_headers = {}
25
- headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
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)
38
+
39
+ ordered_headers.to_h
40
+ end
41
+
42
+ private
26
43
 
27
- ordered_headers = required_header_keys.map do |key|
44
+ def validate_declared_headers!(required_header_keys, normalised_headers)
45
+ required_header_keys.map do |key|
28
46
  value = normalised_headers[key.downcase]
29
47
 
30
- raise(Error, "Missing header(s) declared in signature") unless value
48
+ raise(Error, "Missing header declared in signature: #{key.downcase}") unless value
31
49
 
32
50
  [key, value]
33
51
  end
34
-
35
- ordered_headers.to_h
36
52
  end
37
53
 
38
- private def retrieve_headers(tl_headers)
39
- tl_headers && tl_headers.is_a?(Hash) && tl_headers.keys.join(",") || tl_headers || ""
54
+ def retrieve_headers(tl_headers)
55
+ (tl_headers.is_a?(Hash) && tl_headers.keys.join(",")) || tl_headers || ""
40
56
  end
41
57
  end
42
58
 
43
59
  class JwsBase
44
60
  attr_reader :method, :path, :headers, :body
45
61
 
46
- def initialize(args = {})
62
+ def initialize(_args = {})
47
63
  @method = "POST"
48
64
  @headers = {}
49
65
  end
50
66
 
51
67
  def set_method(method)
52
68
  @method = method.to_s.upcase
69
+
53
70
  self
54
71
  end
55
72
 
@@ -57,33 +74,39 @@ module TrueLayerSigning
57
74
  raise(Error, "Path must start with '/'") unless path.start_with?("/")
58
75
 
59
76
  @path = path
77
+
60
78
  self
61
79
  end
62
80
 
63
81
  def add_header(name, value)
64
82
  @headers[name.to_s] = value
83
+
65
84
  self
66
85
  end
67
86
 
68
87
  def set_headers(headers)
69
88
  headers.each { |name, value| @headers[name.to_s] = value }
89
+
70
90
  self
71
91
  end
72
92
 
73
93
  def set_body(body)
74
94
  @body = body
95
+
75
96
  self
76
97
  end
77
98
 
78
- private def build_signing_payload(custom_headers = nil)
99
+ private
100
+
101
+ def build_signing_payload(custom_headers = nil)
79
102
  parts = []
80
103
  parts.push("#{method.upcase} #{path}")
81
- parts.push(custom_headers && format_headers(custom_headers) || format_headers(headers))
104
+ parts.push((custom_headers && format_headers(custom_headers)) || format_headers(headers))
82
105
  parts.push(body)
83
106
  parts.reject { |elem| elem.nil? || elem.empty? }.join("\n")
84
107
  end
85
108
 
86
- private def format_headers(headers)
109
+ def format_headers(headers)
87
110
  headers.map { |key, value| "#{key}: #{value}" }.join("\n")
88
111
  end
89
112
  end
@@ -1,11 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module TrueLayerSigning
2
4
  class Verifier < JwsBase
3
- EXPECTED_COORDS_LENGTH = 66.freeze
5
+ EXPECTED_EC_KEY_COORDS_LENGTH = 66
4
6
 
5
7
  attr_reader :required_headers, :key_type, :key_value
6
8
 
7
9
  def initialize(args)
8
10
  super
11
+
9
12
  @key_type = args[:key_type]
10
13
  @key_value = args[:key_value]
11
14
  end
@@ -15,14 +18,12 @@ module TrueLayerSigning
15
18
 
16
19
  jws_header, jws_header_b64, signature_b64 = self.class.parse_tl_signature(tl_signature)
17
20
 
18
- raise(Error, "Unexpected `alg` header value") if jws_header.alg != TrueLayerSigning.algorithm
21
+ validate_algorithm!(jws_header.alg)
19
22
 
20
23
  ordered_headers = jws_header.filter_headers(headers)
21
- normalised_headers = {}
22
- ordered_headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
24
+ normalised_headers = Utils.normalise_headers!(ordered_headers)
23
25
 
24
- raise(Error, "Signature missing required header(s)") if required_headers &&
25
- required_headers.any? { |key| !normalised_headers.has_key?(key.downcase) }
26
+ validate_required_headers!(normalised_headers)
26
27
 
27
28
  verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
28
29
  end
@@ -30,11 +31,13 @@ module TrueLayerSigning
30
31
  def require_header(name)
31
32
  @required_headers ||= []
32
33
  @required_headers.push(name)
34
+
33
35
  self
34
36
  end
35
37
 
36
38
  def require_headers(names)
37
39
  @required_headers = names
40
+
38
41
  self
39
42
  end
40
43
 
@@ -54,13 +57,15 @@ module TrueLayerSigning
54
57
  [jws_header, jws_header_b64, signature_b64]
55
58
  end
56
59
 
57
- private def verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
60
+ private
61
+
62
+ def verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
58
63
  full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
59
64
 
60
65
  begin
61
66
  verify_signature(jws_header, full_signature)
62
67
  rescue JWT::VerificationError
63
- @path = path.end_with?("/") && path[0...-1] || path + "/"
68
+ @path = path.end_with?("/") ? path[0...-1] : "#{path}/"
64
69
  full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
65
70
 
66
71
  begin
@@ -71,13 +76,13 @@ module TrueLayerSigning
71
76
  end
72
77
  end
73
78
 
74
- private def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
79
+ def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
75
80
  payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers), padding: false)
76
81
 
77
82
  [jws_header_b64, payload_b64, signature_b64].join(".")
78
83
  end
79
84
 
80
- private def verify_signature(jws_header, full_signature)
85
+ def verify_signature(jws_header, full_signature)
81
86
  case key_type
82
87
  when :pem
83
88
  public_key = OpenSSL::PKey.read(key_value)
@@ -85,11 +90,16 @@ module TrueLayerSigning
85
90
  public_key = retrieve_public_key(:jwks, key_value, jws_header)
86
91
  end
87
92
 
88
- jwt_options = { algorithm: TrueLayerSigning.algorithm }
89
- JWT.truelayer_decode(full_signature, public_key, true, jwt_options)
93
+ jwt_options = {
94
+ algorithm: TrueLayerSigning.algorithm,
95
+ verify_expiration: false,
96
+ verify_not_before: false
97
+ }
98
+
99
+ JWT.truelayer_decode(full_signature, public_key, jwt_options)
90
100
  end
91
101
 
92
- private def retrieve_public_key(key_type, key_value, jws_header)
102
+ def retrieve_public_key(key_type, key_value, jws_header)
93
103
  case key_type
94
104
  when :pem
95
105
  OpenSSL::PKey.read(key_value)
@@ -107,21 +117,30 @@ module TrueLayerSigning
107
117
  end
108
118
  end
109
119
 
110
- private def apply_zero_padding_as_needed(jwk)
120
+ def apply_zero_padding_as_needed(jwk)
111
121
  valid_jwk = jwk.clone
112
122
 
113
123
  %i(x y).each do |elem|
114
124
  coords = Base64.urlsafe_decode64(valid_jwk[elem])
115
- length = coords.length
116
- diff = EXPECTED_COORDS_LENGTH - length
125
+ diff = EXPECTED_EC_KEY_COORDS_LENGTH - coords.length
117
126
 
118
- valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff > 0
127
+ valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff.positive?
119
128
  end
120
129
 
121
130
  valid_jwk
122
131
  end
123
132
 
124
- private def ensure_verifier_config!
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!
125
144
  raise(Error, "Key value missing") unless key_value
126
145
  end
127
146
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "base64"
2
4
  require "forwardable"
3
5
  require "jwt"
@@ -0,0 +1,12 @@
1
+ {
2
+ "type": "payment_failed",
3
+ "event_version": 1,
4
+ "event_id": "6f00897f-f8c8-4ffb-93a3-cfbd311396e2",
5
+ "payment_id": "2c4db314-b509-4d68-b883-a34ca5fa7b72",
6
+ "payment_method": {
7
+ "type": "bank_transfer"
8
+ },
9
+ "failed_at": "2023-07-11T17:42:24.123Z",
10
+ "failure_stage": "authorization_required",
11
+ "failure_reason": "expired"
12
+ }
@@ -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".freeze
9
- PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem").freeze
10
- PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem").freeze
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.freeze
13
- TrueLayerSigning.private_key = PRIVATE_KEY.freeze
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: " + idempotency_key + "\n"))
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: " + idempotency_key + "\n"))
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
@@ -314,7 +316,7 @@ class TrueLayerSigningTest < Minitest::Test
314
316
  .set_body(body)
315
317
 
316
318
  error = assert_raises(TrueLayerSigning::Error) { verifier.verify(tl_signature) }
317
- assert_equal("Missing header(s) declared in signature", error.message)
319
+ assert_equal("Missing header declared in signature: idempotency-key", error.message)
318
320
  end
319
321
 
320
322
  def test_full_request_signature_missing_required_header_should_fail
@@ -455,39 +457,61 @@ class TrueLayerSigningTest < Minitest::Test
455
457
  assert_equal("Signature verification failed", error.message)
456
458
  end
457
459
 
460
+ # This test reproduces an issue we had with an edge case
461
+ def test_verify_with_failed_payment_expired_webhook_should_succeed
462
+ path = "/tl-webhook"
463
+ payload = read_file("resources/failed-payment-expired-test-payload.json")
464
+ body = JSON.parse(payload).to_json
465
+
466
+ tl_signature = TrueLayerSigning.sign_with_pem
467
+ .set_method(:post)
468
+ .set_path(path)
469
+ .set_body(body)
470
+ .sign
471
+
472
+ result = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
473
+ .set_method(:post)
474
+ .set_path(path)
475
+ .set_body(body)
476
+ .verify(tl_signature)
477
+
478
+ assert(result.first.start_with?("POST /tl-webhook\n"))
479
+ assert(result.first.include?("\"failure_reason\":\"expired\""))
480
+ end
481
+
458
482
  def test_sign_with_pem_and_custom_jku_should_succeed
459
483
  body = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
460
484
  idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"
461
485
  path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
462
486
 
463
- tl_signature_1 = TrueLayerSigning.sign_with_pem
487
+ tl_signature_a = TrueLayerSigning.sign_with_pem
464
488
  .set_path(path)
465
489
  .add_header("Idempotency-Key", idempotency_key)
466
490
  .set_body(body)
467
491
  .sign
468
492
 
469
- jws_header_1 = TrueLayerSigning.extract_jws_header(tl_signature_1)
493
+ jws_header_a = TrueLayerSigning.extract_jws_header(tl_signature_a)
470
494
 
471
- assert_nil(jws_header_1.jku)
495
+ assert_nil(jws_header_a.jku)
472
496
 
473
- tl_signature_2 = TrueLayerSigning.sign_with_pem
497
+ tl_signature_b = TrueLayerSigning.sign_with_pem
474
498
  .set_path(path)
475
499
  .add_header("Idempotency-Key", idempotency_key)
476
500
  .set_body(body)
477
501
  .set_jku("https://webhooks.truelayer.com/.well-known/jwks")
478
502
  .sign
479
503
 
480
- jws_header_2 = TrueLayerSigning.extract_jws_header(tl_signature_2)
504
+ jws_header_b = TrueLayerSigning.extract_jws_header(tl_signature_b)
481
505
 
482
- assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header_2.jku)
506
+ assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header_b.jku)
483
507
  end
484
508
 
485
509
  # TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
486
510
  def test_jwt_encode_and_decode_should_succeed
487
511
  payload_object = { currency: "GBP", max_amount_in_minor: 50_000_00 }
488
- token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" +
512
+ token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" \
489
513
  "91bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
490
- token_when_json = "eyJhbGciOiJIUzI1NiJ9.IntcImN1cnJlbmN5XCI6XCJHQlBcIixcIm1h" +
514
+ token_when_json = "eyJhbGciOiJIUzI1NiJ9.IntcImN1cnJlbmN5XCI6XCJHQlBcIixcIm1h" \
491
515
  "eF9hbW91bnRfaW5fbWlub3JcIjo1MDAwMDAwfSI.rvCcgu-JevsNxbjUwJiFOuTd0hzVKvPK5RvGmaoDc7E"
492
516
 
493
517
  # succeeds with a hash object
@@ -508,7 +532,7 @@ class TrueLayerSigningTest < Minitest::Test
508
532
  # TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
509
533
  def test_jwt_truelayer_encode_and_decode_when_given_json_should_succeed
510
534
  payload_json = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
511
- token_when_json = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW9" +
535
+ token_when_json = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW9" \
512
536
  "1bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
513
537
 
514
538
  assert_equal(token_when_json, JWT.truelayer_encode(payload_json, "12345", "HS256", {}))
@@ -527,7 +551,7 @@ class TrueLayerSigningTest < Minitest::Test
527
551
 
528
552
  # TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
529
553
  def test_jwt_truelayer_decode_when_given_a_hash_should_succeed
530
- token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" +
554
+ token_when_object = "eyJhbGciOiJIUzI1NiJ9.eyJjdXJyZW5jeSI6IkdCUCIsIm1heF9hbW" \
531
555
  "91bnRfaW5fbWlub3IiOjUwMDAwMDB9.SjbwZCqTl6G7LQNs_M6oQhwl3a9rbqO7p3cVncLtgZY"
532
556
 
533
557
  assert_equal(
@@ -1,8 +1,10 @@
1
- $LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib"))
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.0"
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
- s.require_paths = ["lib"]
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", "~> 2.7")
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.0
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-06-13 00:00:00.000000000 Z
11
+ date: 2023-12-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt
@@ -32,30 +32,25 @@ executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
- - "./CHANGELOG.md"
36
- - "./LICENSE-APACHE"
37
- - "./LICENSE-MIT"
38
- - "./README.md"
39
- - "./Rakefile"
40
- - "./examples/sign-request/Gemfile"
41
- - "./examples/sign-request/README.md"
42
- - "./examples/sign-request/main.rb"
43
- - "./examples/webhook-server/Gemfile"
44
- - "./examples/webhook-server/Gemfile.lock"
45
- - "./examples/webhook-server/README.md"
46
- - "./examples/webhook-server/main.rb"
47
- - "./lib/truelayer-signing.rb"
48
- - "./lib/truelayer-signing/config.rb"
49
- - "./lib/truelayer-signing/errors.rb"
50
- - "./lib/truelayer-signing/jwt.rb"
51
- - "./lib/truelayer-signing/signer.rb"
52
- - "./lib/truelayer-signing/utils.rb"
53
- - "./lib/truelayer-signing/verifier.rb"
54
- - "./test/resources/missing-zero-padding-test-jwks.json"
55
- - "./test/resources/missing-zero-padding-test-payload.json"
56
- - "./test/resources/missing-zero-padding-test-signature.txt"
57
- - "./test/test-truelayer-signing.rb"
58
- - "./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
59
54
  homepage: https://github.com/TrueLayer/truelayer-signing/tree/main/ruby
60
55
  licenses:
61
56
  - Apache-2.0
@@ -63,7 +58,7 @@ licenses:
63
58
  metadata:
64
59
  bug_tracker_uri: https://github.com/TrueLayer/truelayer-signing/issues
65
60
  changelog_uri: https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md
66
- post_install_message:
61
+ post_install_message:
67
62
  rdoc_options: []
68
63
  require_paths:
69
64
  - lib
@@ -78,8 +73,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
73
  - !ruby/object:Gem::Version
79
74
  version: '0'
80
75
  requirements: []
81
- rubygems_version: 3.4.13
82
- signing_key:
76
+ rubygems_version: 3.4.10
77
+ signing_key:
83
78
  specification_version: 4
84
79
  summary: Ruby gem to produce and verify TrueLayer API requests signatures
85
80
  test_files: []
@@ -1,4 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gem "http"
4
- gem "truelayer-signing"
@@ -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,5 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gem "http"
4
- gem "truelayer-signing"
5
- gem "webrick"
@@ -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