truelayer-signing 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|