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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 751c6c37cee7adedf204491894146d577e70b310d9cd50504c01c29836743271
4
- data.tar.gz: d2b7de41a966d0e114dd368320f35578714a674da1d76121d500944f0c33596b
3
+ metadata.gz: fd87086c0bf22e70c924a9b3cc97e600070d0c8840b07edc56cff847177231a8
4
+ data.tar.gz: 86633894d1b455bc43e2bbc41d175deb47b5288411355d0bc5a96768625acb13
5
5
  SHA512:
6
- metadata.gz: 88a7bc727e82df06eccb1992036730e49f6de45a6d38d6df7d2cf8d749441a8d1f5be031e59410e45778da20d84fac03bb07f7bf11103387893baebd4d86af2b
7
- data.tar.gz: 249a44e6a94171f89f69f1e53aff377bee6ac6de33a3f2383a6c7f4ed94b517e216cd81da450caf1f363f21e1a8f60edcb6dfc71481af0c7fc70336d028ea0de
6
+ metadata.gz: d2698bda8922e5daec55f4e5f25721f0f498a8e0485aff3e7852b1c0a2bf39363ca5b37674f294513ac752521325aa6eaefb924fc45bf42ea44fe948061aefc7
7
+ data.tar.gz: 5494b6be736e4d17dab4bb32f5d2c53edb331a4fa5dc67c17f1eb6845ec547aec6bb9f8e10529cf349a674b1a3be985d2af9ef8837620b50a8401fc7e0f60d3a
data/CHANGELOG.md CHANGED
@@ -9,6 +9,10 @@ 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
+
12
16
  ## [0.2.1] – 2023-07-14
13
17
 
14
18
  - Disable expiration verification
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,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
- jws_header_args = { tl_headers: headers }
10
-
11
- jws_header_args[:jku] = jws_jku if jws_jku
12
-
13
- jws_header = TrueLayerSigning::JwsHeader.new(jws_header_args).to_h
14
- jwt = JWT.truelayer_encode(build_signing_payload, private_key, TrueLayerSigning.algorithm,
15
- 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
+ )
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 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!
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.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)] }
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 { |key| key.empty? }
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
- headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
39
+ ordered_headers.to_h
40
+ end
41
+
42
+ private
28
43
 
29
- 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|
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
- private def retrieve_headers(tl_headers)
41
- 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 || ""
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(args = {})
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 def build_signing_payload(custom_headers = nil)
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
- private def format_headers(headers)
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.freeze
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
- raise(Error, "Unexpected `alg` header value") if jws_header.alg != TrueLayerSigning.algorithm
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
- raise(Error, "Signature missing required header(s)") if required_headers &&
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 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)
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?("/") && path[0...-1] || path + "/"
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
- private def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
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
- private def verify_signature(jws_header, full_signature)
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
- private def retrieve_public_key(key_type, key_value, jws_header)
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
- private def apply_zero_padding_as_needed(jwk)
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 > 0
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
- 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!
133
144
  raise(Error, "Key value missing") unless key_value
134
145
  end
135
146
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "base64"
2
4
  require "forwardable"
3
5
  require "jwt"
@@ -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
@@ -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
- tl_signature_1 = TrueLayerSigning.sign_with_pem
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
- jws_header_1 = TrueLayerSigning.extract_jws_header(tl_signature_1)
493
+ jws_header_a = TrueLayerSigning.extract_jws_header(tl_signature_a)
492
494
 
493
- assert_nil(jws_header_1.jku)
495
+ assert_nil(jws_header_a.jku)
494
496
 
495
- tl_signature_2 = TrueLayerSigning.sign_with_pem
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
- jws_header_2 = TrueLayerSigning.extract_jws_header(tl_signature_2)
504
+ jws_header_b = TrueLayerSigning.extract_jws_header(tl_signature_b)
503
505
 
504
- 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)
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(
@@ -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.1"
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.1
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-14 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,31 +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/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.13
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,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