truelayer-signing 0.2.1 → 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: 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