truelayer-signing 0.2.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
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