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 +4 -4
- data/CHANGELOG.md +9 -0
- data/Gemfile +5 -0
- data/Rakefile +2 -0
- data/lib/truelayer-signing/config.rb +6 -4
- data/lib/truelayer-signing/errors.rb +2 -0
- data/lib/truelayer-signing/jwt.rb +12 -4
- data/lib/truelayer-signing/signer.rb +22 -10
- data/lib/truelayer-signing/utils.rb +39 -16
- data/lib/truelayer-signing/verifier.rb +37 -18
- data/lib/truelayer-signing.rb +2 -0
- data/test/resources/failed-payment-expired-test-payload.json +12 -0
- data/test/test-truelayer-signing.rb +52 -28
- data/truelayer-signing.gemspec +17 -6
- metadata +25 -30
- data/examples/sign-request/Gemfile +0 -4
- data/examples/sign-request/README.md +0 -27
- data/examples/sign-request/main.rb +0 -46
- data/examples/webhook-server/Gemfile +0 -5
- data/examples/webhook-server/Gemfile.lock +0 -42
- data/examples/webhook-server/README.md +0 -30
- data/examples/webhook-server/main.rb +0 -95
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fd87086c0bf22e70c924a9b3cc97e600070d0c8840b07edc56cff847177231a8
|
4
|
+
data.tar.gz: 86633894d1b455bc43e2bbc41d175deb47b5288411355d0bc5a96768625acb13
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d2698bda8922e5daec55f4e5f25721f0f498a8e0485aff3e7852b1c0a2bf39363ca5b37674f294513ac752521325aa6eaefb924fc45bf42ea44fe948061aefc7
|
7
|
+
data.tar.gz: 5494b6be736e4d17dab4bb32f5d2c53edb331a4fa5dc67c17f1eb6845ec547aec6bb9f8e10529cf349a674b1a3be985d2af9ef8837620b50a8401fc7e0f60d3a
|
data/CHANGELOG.md
CHANGED
@@ -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
data/Rakefile
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module TrueLayerSigning
|
2
4
|
class Config
|
3
5
|
attr_accessor :certificate_id, :private_key
|
@@ -12,10 +14,10 @@ module TrueLayerSigning
|
|
12
14
|
|
13
15
|
# @return [TrueLayerSigning::Config]
|
14
16
|
def initialize
|
15
|
-
@algorithm = "ES512"
|
16
|
-
@certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil)
|
17
|
-
@private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub(
|
18
|
-
@version = "2"
|
17
|
+
@algorithm = "ES512"
|
18
|
+
@certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil)
|
19
|
+
@private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub("\\n", "\n")
|
20
|
+
@version = "2"
|
19
21
|
end
|
20
22
|
end
|
21
23
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# TODO: this is a custom patch of payload-related methods, from the 'jwt' gem.
|
2
4
|
# It prevents the payload from being systematically converted to and from JSON.
|
3
5
|
# To be changed in the 'jwt' gem directly, or hard-coded in this library.
|
@@ -5,18 +7,22 @@ module JWT
|
|
5
7
|
module_function
|
6
8
|
|
7
9
|
class TrueLayerEncode < Encode
|
10
|
+
private
|
11
|
+
|
8
12
|
# See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/encode.rb#L53-L55
|
9
|
-
|
13
|
+
def encode_payload
|
10
14
|
::JWT::Base64.url_encode(@payload)
|
11
15
|
end
|
12
16
|
end
|
13
17
|
|
14
18
|
class TrueLayerDecode < Decode
|
19
|
+
private
|
20
|
+
|
15
21
|
# See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb#L154-L156
|
16
|
-
|
22
|
+
def payload
|
17
23
|
@payload ||= ::JWT::Base64.url_decode(@segments[1])
|
18
24
|
rescue ::JSON::ParserError
|
19
|
-
raise JWT::DecodeError,
|
25
|
+
raise JWT::DecodeError, "Invalid segment encoding"
|
20
26
|
end
|
21
27
|
end
|
22
28
|
|
@@ -29,7 +35,8 @@ module JWT
|
|
29
35
|
).segments
|
30
36
|
end
|
31
37
|
|
32
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
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.
|
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
|
24
|
-
normalised_headers =
|
25
|
-
|
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
|
-
|
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
|
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
|
-
|
39
|
-
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(
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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?("/")
|
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
|
-
|
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
|
-
|
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 = {
|
89
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
data/lib/truelayer-signing.rb
CHANGED
@@ -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"
|
9
|
-
PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem")
|
10
|
-
PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem")
|
10
|
+
CERTIFICATE_ID = "45fc75cf-5649-4134-84b3-192c2c78e990"
|
11
|
+
PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem")
|
12
|
+
PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem")
|
11
13
|
|
12
|
-
TrueLayerSigning.certificate_id = CERTIFICATE_ID
|
13
|
-
TrueLayerSigning.private_key = PRIVATE_KEY
|
14
|
+
TrueLayerSigning.certificate_id = CERTIFICATE_ID
|
15
|
+
TrueLayerSigning.private_key = PRIVATE_KEY
|
14
16
|
|
15
17
|
class TrueLayerSigningTest < Minitest::Test
|
16
18
|
def test_full_request_signature_should_succeed
|
@@ -35,7 +37,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
35
37
|
.verify(tl_signature)
|
36
38
|
|
37
39
|
refute(result.first.include?("\nX-Whatever: aoitbeh\n"))
|
38
|
-
assert(result.first.include?("\nIdempotency-Key:
|
40
|
+
assert(result.first.include?("\nIdempotency-Key: #{idempotency_key}\n"))
|
39
41
|
assert(result.first
|
40
42
|
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
41
43
|
end
|
@@ -82,11 +84,11 @@ class TrueLayerSigningTest < Minitest::Test
|
|
82
84
|
def test_mismatched_signature_with_attached_valid_body_should_fail
|
83
85
|
# Signature for `/bar` but with a valid jws-body pre-attached.
|
84
86
|
# If we run a simple jws verify on this unchanged, it'll work!
|
85
|
-
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND"
|
86
|
-
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV"
|
87
|
-
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD"
|
88
|
-
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC"
|
89
|
-
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB"
|
87
|
+
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" \
|
88
|
+
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" \
|
89
|
+
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" \
|
90
|
+
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" \
|
91
|
+
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" \
|
90
92
|
"d2d3D17Wd9UA"
|
91
93
|
|
92
94
|
verifier = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
@@ -101,11 +103,11 @@ class TrueLayerSigningTest < Minitest::Test
|
|
101
103
|
def test_mismatched_signature_with_attached_valid_body_and_trailing_dots_should_fail
|
102
104
|
# Signature for `/bar` but with a valid jws-body pre-attached.
|
103
105
|
# If we run a simple jws verify on this unchanged, it'll work!
|
104
|
-
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND"
|
105
|
-
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV"
|
106
|
-
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD"
|
107
|
-
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC"
|
108
|
-
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB"
|
106
|
+
tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" \
|
107
|
+
"ktndeZnC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" \
|
108
|
+
"hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" \
|
109
|
+
"z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" \
|
110
|
+
"QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" \
|
109
111
|
"d2d3D17Wd9UA...."
|
110
112
|
|
111
113
|
verifier = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
@@ -132,7 +134,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
132
134
|
.verify(tl_signature)
|
133
135
|
|
134
136
|
refute(result.first.include?("\nX-Whatever-2: t2345d\n"))
|
135
|
-
assert(result.first.include?("\nIdempotency-Key:
|
137
|
+
assert(result.first.include?("\nIdempotency-Key: #{idempotency_key}\n"))
|
136
138
|
assert(result.first
|
137
139
|
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
138
140
|
end
|
@@ -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
|
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
|
-
|
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
|
-
|
493
|
+
jws_header_a = TrueLayerSigning.extract_jws_header(tl_signature_a)
|
470
494
|
|
471
|
-
assert_nil(
|
495
|
+
assert_nil(jws_header_a.jku)
|
472
496
|
|
473
|
-
|
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
|
-
|
504
|
+
jws_header_b = TrueLayerSigning.extract_jws_header(tl_signature_b)
|
481
505
|
|
482
|
-
assert_equal("https://webhooks.truelayer.com/.well-known/jwks",
|
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(
|
data/truelayer-signing.gemspec
CHANGED
@@ -1,8 +1,10 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "lib"))
|
2
4
|
|
3
5
|
Gem::Specification.new do |s|
|
4
6
|
s.name = "truelayer-signing"
|
5
|
-
s.version = "0.2.
|
7
|
+
s.version = "0.2.2"
|
6
8
|
s.summary = "Ruby gem to produce and verify TrueLayer API requests signatures"
|
7
9
|
s.description = "TrueLayer provides instant access to open banking to " \
|
8
10
|
"easily integrate next-generation payments and financial data into any app." \
|
@@ -14,12 +16,21 @@ Gem::Specification.new do |s|
|
|
14
16
|
|
15
17
|
s.metadata = {
|
16
18
|
"bug_tracker_uri" => "https://github.com/TrueLayer/truelayer-signing/issues",
|
17
|
-
"changelog_uri" => "https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md"
|
19
|
+
"changelog_uri" => "https://github.com/TrueLayer/truelayer-signing/blob/main/ruby/CHANGELOG.md"
|
18
20
|
}
|
19
21
|
|
20
|
-
s.files = Dir[
|
21
|
-
|
22
|
+
s.files = Dir[
|
23
|
+
"CHANGELOG.md",
|
24
|
+
"Gemfile",
|
25
|
+
"LICENSE*",
|
26
|
+
"README.md",
|
27
|
+
"Rakefile",
|
28
|
+
"lib/**/*.*",
|
29
|
+
"test/**/*.*",
|
30
|
+
"truelayer-signing.gemspec",
|
31
|
+
]
|
32
|
+
s.require_path = "lib"
|
22
33
|
|
23
34
|
s.required_ruby_version = ">= 2.7"
|
24
|
-
s.add_runtime_dependency("jwt",
|
35
|
+
s.add_runtime_dependency("jwt", "~> 2.7")
|
25
36
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: truelayer-signing
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kevin Plattret
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
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
|
-
-
|
36
|
-
-
|
37
|
-
-
|
38
|
-
-
|
39
|
-
-
|
40
|
-
-
|
41
|
-
-
|
42
|
-
-
|
43
|
-
-
|
44
|
-
-
|
45
|
-
-
|
46
|
-
-
|
47
|
-
-
|
48
|
-
-
|
49
|
-
-
|
50
|
-
-
|
51
|
-
-
|
52
|
-
-
|
53
|
-
-
|
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.
|
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,27 +0,0 @@
|
|
1
|
-
# Ruby request signature example
|
2
|
-
|
3
|
-
Sends a signed request to `https://api.truelayer-sandbox.com/test-signature`.
|
4
|
-
|
5
|
-
## Run
|
6
|
-
|
7
|
-
Set the following environment variables:
|
8
|
-
|
9
|
-
* `TRUELAYER_SIGNING_ACCESS_TOKEN` – a valid JWT access token for the `payments` scope (see our
|
10
|
-
[docs](https://docs.truelayer.com/docs/retrieve-a-token-in-your-server)).
|
11
|
-
* `TRUELAYER_SIGNING_CERTIFICATE_ID` – the certificate/key UUID associated with your public key
|
12
|
-
uploaded at [console.truelayer.com](https://console.truelayer.com).
|
13
|
-
* `TRUELAYER_SIGNING_PRIVATE_KEY` – the private key PEM string that matches the certificate ID of the
|
14
|
-
uploaded public key. Should have the same format as [this example private
|
15
|
-
key](https://github.com/TrueLayer/truelayer-signing/blob/main/test-resources/ec512-private.pem).
|
16
|
-
|
17
|
-
Install the required dependencies:
|
18
|
-
|
19
|
-
```sh
|
20
|
-
$ bundle
|
21
|
-
```
|
22
|
-
|
23
|
-
Execute the request-signing example script:
|
24
|
-
|
25
|
-
```sh
|
26
|
-
$ ruby main.rb
|
27
|
-
```
|
@@ -1,46 +0,0 @@
|
|
1
|
-
require "http"
|
2
|
-
require "securerandom"
|
3
|
-
require "truelayer-signing"
|
4
|
-
|
5
|
-
class TrueLayerSigningExamples
|
6
|
-
# Set required environment variables
|
7
|
-
TRUELAYER_SIGNING_ACCESS_TOKEN = ENV.fetch("TRUELAYER_SIGNING_ACCESS_TOKEN", nil).freeze
|
8
|
-
TRUELAYER_SIGNING_BASE_URL = "https://api.truelayer-sandbox.com".freeze
|
9
|
-
|
10
|
-
raise(StandardError, "TRUELAYER_SIGNING_ACCESS_TOKEN is missing") \
|
11
|
-
if TRUELAYER_SIGNING_ACCESS_TOKEN.nil? || TRUELAYER_SIGNING_ACCESS_TOKEN.empty?
|
12
|
-
|
13
|
-
class << self
|
14
|
-
def test_signature_endpoint
|
15
|
-
url = "#{TRUELAYER_SIGNING_BASE_URL}/test-signature"
|
16
|
-
idempotency_key = SecureRandom.uuid
|
17
|
-
|
18
|
-
# A random body string is enough for this request as the `/test-signature` endpoint does not
|
19
|
-
# require any schema, it simply checks the signature is valid against what's received.
|
20
|
-
body = "body-#{SecureRandom.uuid}"
|
21
|
-
|
22
|
-
# Generate a `Tl-Signature`
|
23
|
-
signature = TrueLayerSigning.sign_with_pem
|
24
|
-
.set_method("POST")
|
25
|
-
.set_path("/test-signature")
|
26
|
-
# Optional: `/test-signature` does not require any headers, but we may sign some anyway.
|
27
|
-
# All signed headers *must* be included unmodified in the request.
|
28
|
-
.add_header("Idempotency-Key", idempotency_key)
|
29
|
-
.add_header("X-Bar-Header", "abc123")
|
30
|
-
.set_body(body)
|
31
|
-
.sign
|
32
|
-
|
33
|
-
response = HTTP.auth("Bearer #{TRUELAYER_SIGNING_ACCESS_TOKEN}")
|
34
|
-
.headers(idempotency_key: idempotency_key)
|
35
|
-
.headers(x_bar_header: "abc123")
|
36
|
-
.headers(tl_signature: signature)
|
37
|
-
.post(url, body: body)
|
38
|
-
|
39
|
-
return puts "✓ Signature is valid" if response.status.success?
|
40
|
-
|
41
|
-
puts JSON.pretty_generate(JSON.parse(response.to_s))
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
TrueLayerSigningExamples.test_signature_endpoint
|
@@ -1,42 +0,0 @@
|
|
1
|
-
GEM
|
2
|
-
remote: https://rubygems.org/
|
3
|
-
specs:
|
4
|
-
addressable (2.8.4)
|
5
|
-
public_suffix (>= 2.0.2, < 6.0)
|
6
|
-
domain_name (0.5.20190701)
|
7
|
-
unf (>= 0.0.5, < 1.0.0)
|
8
|
-
ffi (1.15.5)
|
9
|
-
ffi-compiler (1.0.1)
|
10
|
-
ffi (>= 1.0.0)
|
11
|
-
rake
|
12
|
-
http (5.1.1)
|
13
|
-
addressable (~> 2.8)
|
14
|
-
http-cookie (~> 1.0)
|
15
|
-
http-form_data (~> 2.2)
|
16
|
-
llhttp-ffi (~> 0.4.0)
|
17
|
-
http-cookie (1.0.5)
|
18
|
-
domain_name (~> 0.5)
|
19
|
-
http-form_data (2.3.0)
|
20
|
-
jwt (2.7.0)
|
21
|
-
llhttp-ffi (0.4.0)
|
22
|
-
ffi-compiler (~> 1.0)
|
23
|
-
rake (~> 13.0)
|
24
|
-
public_suffix (5.0.1)
|
25
|
-
rake (13.0.6)
|
26
|
-
truelayer-signing (0.2.0)
|
27
|
-
jwt (~> 2.7)
|
28
|
-
unf (0.1.4)
|
29
|
-
unf_ext
|
30
|
-
unf_ext (0.0.8.2)
|
31
|
-
webrick (1.8.1)
|
32
|
-
|
33
|
-
PLATFORMS
|
34
|
-
arm64-darwin-21
|
35
|
-
|
36
|
-
DEPENDENCIES
|
37
|
-
http
|
38
|
-
truelayer-signing
|
39
|
-
webrick
|
40
|
-
|
41
|
-
BUNDLED WITH
|
42
|
-
2.4.13
|
@@ -1,30 +0,0 @@
|
|
1
|
-
# Ruby webhook server example
|
2
|
-
|
3
|
-
An HTTP server that can receive and verify signed TrueLayer webhooks.
|
4
|
-
|
5
|
-
## Run
|
6
|
-
|
7
|
-
Install the required dependencies:
|
8
|
-
|
9
|
-
```sh
|
10
|
-
$ bundle
|
11
|
-
```
|
12
|
-
|
13
|
-
Run the server locally:
|
14
|
-
|
15
|
-
```sh
|
16
|
-
$ ruby main.rb
|
17
|
-
```
|
18
|
-
|
19
|
-
Send a valid webhook that was signed for the path `/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b`:
|
20
|
-
|
21
|
-
```sh
|
22
|
-
curl -iX POST -H "Content-Type: application/json" \
|
23
|
-
-H "X-Tl-Webhook-Timestamp: 2022-03-11T14:00:33Z" \
|
24
|
-
-H "Tl-Signature: eyJhbGciOiJFUzUxMiIsImtpZCI6IjFmYzBlNTlmLWIzMzUtNDdjYS05OWE5LTczNzQ5NTc1NmE1OCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGVhZGVycyI6IngtdGwtd2ViaG9vay10aW1lc3RhbXAiLCJqa3UiOiJodHRwczovL3dlYmhvb2tzLnRydWVsYXllci5jb20vLndlbGwta25vd24vandrcyJ9..AE_QsBRhnsMkcRzd42wvY1e2HruUhkOgjuZKktGH_WmbD7rBzoaEHUuF36IxyyvCbLajd3MBExNmzjbrOQsGaspwAI5DcGVMFLKUhB7ZzUlTP9up3eNUrdwWyyfBWDQb-qmEuLnrhFDJvgCXEqlV5OLrt-O7LaRAJ4f9KHsZLQ_j2vPC" \
|
25
|
-
-d "{\"event_type\":\"payout_settled\",\"event_schema_version\":1,\"event_id\":\"8fb9fb4e-bb2b-400b-af64-59e5dde74bad\",\"event_body\":{\"transaction_id\":\"c34c8721-66a9-49f6-a229-284efcf88a02\",\"settled_at\":\"2022-03-11T14:00:32.933000Z\"}}" \
|
26
|
-
http://localhost:4567/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b
|
27
|
-
```
|
28
|
-
|
29
|
-
Modifying the `X-Tl-Webhook-Timestamp` header, the body or the path of the request will cause the
|
30
|
-
above signature to be invalid.
|
@@ -1,95 +0,0 @@
|
|
1
|
-
require "http"
|
2
|
-
require "truelayer-signing"
|
3
|
-
require "webrick"
|
4
|
-
|
5
|
-
class TrueLayerSigningExamples
|
6
|
-
# Note: the webhook path can be whatever is configured for your application.
|
7
|
-
# Here a unique path is used, matching the example signature in the README.
|
8
|
-
WEBHOOK_PATH = "/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b".freeze
|
9
|
-
|
10
|
-
class << self
|
11
|
-
def run_webhook_server
|
12
|
-
server = WEBrick::HTTPServer.new(Port: 4567)
|
13
|
-
|
14
|
-
puts "Server running at http://localhost:4567"
|
15
|
-
|
16
|
-
server.mount_proc('/') do |req, res|
|
17
|
-
request = parse_request(req)
|
18
|
-
status, body = handle_request.call(request)
|
19
|
-
headers = { "Content-Type" => "text/plain" }
|
20
|
-
|
21
|
-
send_response(res, status, headers, body)
|
22
|
-
rescue => error
|
23
|
-
puts error
|
24
|
-
end
|
25
|
-
|
26
|
-
server.start
|
27
|
-
ensure
|
28
|
-
server.shutdown
|
29
|
-
end
|
30
|
-
|
31
|
-
private def parse_request(request)
|
32
|
-
{
|
33
|
-
method: request.request_method,
|
34
|
-
path: request.path,
|
35
|
-
headers: headers_to_hash(request.header),
|
36
|
-
body: request.body
|
37
|
-
}
|
38
|
-
end
|
39
|
-
|
40
|
-
private def handle_request
|
41
|
-
Proc.new do |request|
|
42
|
-
if request[:method] == "POST" && request[:path] == WEBHOOK_PATH
|
43
|
-
verify_webhook(request[:path], request[:headers], request[:body])
|
44
|
-
else
|
45
|
-
["403", "Forbidden"]
|
46
|
-
end
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
private def verify_webhook(path, headers, body)
|
51
|
-
tl_signature = headers["tl-signature"]
|
52
|
-
|
53
|
-
return ["400", "Bad Request – Header `Tl-Signature` missing"] unless tl_signature
|
54
|
-
|
55
|
-
jku = TrueLayerSigning.extract_jws_header(tl_signature).jku
|
56
|
-
|
57
|
-
return ["400", "Bad Request – Signature missing `jku`"] unless jku
|
58
|
-
return ["401", "Unauthorized – Unpermitted `jku`"] \
|
59
|
-
unless jku == "https://webhooks.truelayer.com/.well-known/jwks" ||
|
60
|
-
jku == "https://webhooks.truelayer-sandbox.com/.well-known/jwks"
|
61
|
-
|
62
|
-
jwks = HTTP.get(jku)
|
63
|
-
|
64
|
-
return ["401", "Unauthorized – Unavailable `jwks` resource"] unless jwks.status.success?
|
65
|
-
|
66
|
-
begin
|
67
|
-
TrueLayerSigning.verify_with_jwks(jwks.to_s)
|
68
|
-
.set_method(:post)
|
69
|
-
.set_path(path)
|
70
|
-
.set_headers(headers)
|
71
|
-
.set_body(body)
|
72
|
-
.verify(tl_signature)
|
73
|
-
|
74
|
-
["202", "Accepted"]
|
75
|
-
rescue TrueLayerSigning::Error => error
|
76
|
-
puts error
|
77
|
-
|
78
|
-
["401", "Unauthorized"]
|
79
|
-
end
|
80
|
-
end
|
81
|
-
|
82
|
-
private def headers_to_hash(headers)
|
83
|
-
headers.transform_keys { |key| key.to_s.strip.downcase }.transform_values(&:first)
|
84
|
-
end
|
85
|
-
|
86
|
-
private def send_response(response, status, headers, body)
|
87
|
-
response.status = status
|
88
|
-
response.header.merge!(headers)
|
89
|
-
response.body = body
|
90
|
-
response
|
91
|
-
end
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
TrueLayerSigningExamples.run_webhook_server
|