Skip to content

Commit 77cb421

Browse files
committed
ValidationClient for client validation, add ClientValidationJwt
1 parent 29d4ec1 commit 77cb421

File tree

5 files changed

+450
-3
lines changed

5 files changed

+450
-3
lines changed

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ nosexcover
66
flake8
77
mccabe
88
wheel>=0.22.0
9+
cryptography
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import unittest
2+
import time
3+
4+
from cryptography.hazmat.backends import default_backend
5+
from cryptography.hazmat.primitives.asymmetric import rsa
6+
from cryptography.hazmat.primitives.serialization import (
7+
Encoding,
8+
PublicFormat,
9+
PrivateFormat,
10+
NoEncryption
11+
)
12+
13+
from twilio.http.validation_client import ValidationPayload
14+
from twilio.jwt import Jwt
15+
from twilio.jwt.validation import ClientValidationJwt
16+
17+
18+
class ClientValidationJwtTest(unittest.TestCase):
19+
def test_generate_payload_basic(self):
20+
vp = ValidationPayload(
21+
method='GET',
22+
url='https://api.twilio.com/',
23+
query_string='q1=v1',
24+
signed_headers=['headerb', 'headera'],
25+
all_headers={'head': 'toe', 'headera': 'vala', 'headerb': 'valb'},
26+
body='me=letop&you=leworst'
27+
)
28+
29+
expected_payload = '\n'.join([
30+
'GET',
31+
'https://api.twilio.com/',
32+
'q1=v1',
33+
'headera:vala',
34+
'headerb:valb',
35+
'',
36+
'headera;headerb',
37+
'{}'.format(ClientValidationJwt._hash('me=letop&you=leworst'))
38+
])
39+
expected_payload = ClientValidationJwt._hash(expected_payload)
40+
41+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
42+
43+
actual_payload = jwt._generate_payload()
44+
self.assertEqual('headera;headerb', actual_payload['hrh'])
45+
self.assertEqual(expected_payload, actual_payload['rqh'])
46+
47+
def test_generate_payload_complex(self):
48+
vp = ValidationPayload(
49+
method='GET',
50+
url='https://api.twilio.com/',
51+
query_string='q1=v1&q2=v2&a=b',
52+
signed_headers=['headerb', 'headera'],
53+
all_headers={'head': 'toe', 'Headerb': 'valb', 'yeezy': 'weezy'},
54+
body='me=letop&you=leworst'
55+
)
56+
57+
expected_payload = '\n'.join([
58+
'GET',
59+
'https://api.twilio.com/',
60+
'a=b&q1=v1&q2=v2',
61+
'headerb:valb',
62+
'',
63+
'headera;headerb',
64+
'{}'.format(ClientValidationJwt._hash('me=letop&you=leworst'))
65+
])
66+
expected_payload = ClientValidationJwt._hash(expected_payload)
67+
68+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
69+
70+
actual_payload = jwt._generate_payload()
71+
self.assertEqual('headera;headerb', actual_payload['hrh'])
72+
self.assertEqual(expected_payload, actual_payload['rqh'])
73+
74+
def test_generate_payload_no_query_string(self):
75+
vp = ValidationPayload(
76+
method='GET',
77+
url='https://api.twilio.com/',
78+
query_string='',
79+
signed_headers=['headerb', 'headera'],
80+
all_headers={'head': 'toe', 'Headerb': 'valb', 'yeezy': 'weezy'},
81+
body='me=letop&you=leworst'
82+
)
83+
84+
expected_payload = '\n'.join([
85+
'GET',
86+
'https://api.twilio.com/',
87+
'',
88+
'headerb:valb',
89+
'',
90+
'headera;headerb',
91+
'{}'.format(ClientValidationJwt._hash('me=letop&you=leworst'))
92+
])
93+
expected_payload = ClientValidationJwt._hash(expected_payload)
94+
95+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
96+
97+
actual_payload = jwt._generate_payload()
98+
self.assertEqual('headera;headerb', actual_payload['hrh'])
99+
self.assertEqual(expected_payload, actual_payload['rqh'])
100+
101+
def test_generate_payload_no_req_body(self):
102+
vp = ValidationPayload(
103+
method='GET',
104+
url='https://api.twilio.com/',
105+
query_string='q1=v1',
106+
signed_headers=['headerb', 'headera'],
107+
all_headers={'head': 'toe', 'headera': 'vala', 'headerb': 'valb'},
108+
body=None
109+
)
110+
111+
expected_payload = '\n'.join([
112+
'GET',
113+
'https://api.twilio.com/',
114+
'q1=v1',
115+
'headera:vala',
116+
'headerb:valb',
117+
'',
118+
'headera;headerb',
119+
])
120+
expected_payload = ClientValidationJwt._hash(expected_payload)
121+
122+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
123+
124+
actual_payload = jwt._generate_payload()
125+
self.assertEqual('headera;headerb', actual_payload['hrh'])
126+
self.assertEqual(expected_payload, actual_payload['rqh'])
127+
128+
def test_generate_payload_header_keys_lowercased(self):
129+
vp = ValidationPayload(
130+
method='GET',
131+
url='https://api.twilio.com/',
132+
query_string='q1=v1',
133+
signed_headers=['headerb', 'headera'],
134+
all_headers={'head': 'toe', 'Headera': 'vala', 'Headerb': 'valb'},
135+
body='me=letop&you=leworst'
136+
)
137+
138+
expected_payload = '\n'.join([
139+
'GET',
140+
'https://api.twilio.com/',
141+
'q1=v1',
142+
'headera:vala',
143+
'headerb:valb',
144+
'',
145+
'headera;headerb',
146+
'{}'.format(ClientValidationJwt._hash('me=letop&you=leworst'))
147+
])
148+
expected_payload = ClientValidationJwt._hash(expected_payload)
149+
150+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
151+
152+
actual_payload = jwt._generate_payload()
153+
self.assertEqual('headera;headerb', actual_payload['hrh'])
154+
self.assertEqual(expected_payload, actual_payload['rqh'])
155+
156+
def test_generate_payload_no_headers(self):
157+
vp = ValidationPayload(
158+
method='GET',
159+
url='https://api.twilio.com/',
160+
query_string='q1=v1',
161+
signed_headers=['headerb', 'headera'],
162+
all_headers={},
163+
body='me=letop&you=leworst'
164+
)
165+
166+
expected_payload = '\n'.join([
167+
'GET',
168+
'https://api.twilio.com/',
169+
'q1=v1',
170+
'',
171+
'headera;headerb',
172+
'{}'.format(ClientValidationJwt._hash('me=letop&you=leworst'))
173+
])
174+
expected_payload = ClientValidationJwt._hash(expected_payload)
175+
176+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
177+
178+
actual_payload = jwt._generate_payload()
179+
self.assertEqual('headera;headerb', actual_payload['hrh'])
180+
self.assertEqual(expected_payload, actual_payload['rqh'])
181+
182+
def test_generate_payload_schema_correct(self):
183+
"""Test against a known good rqh payload hash"""
184+
vp = ValidationPayload(
185+
method='GET',
186+
url='/Messages',
187+
query_string='PageSize=5&Limit=10',
188+
signed_headers=['authorization', 'host'],
189+
all_headers={'authorization': 'foobar', 'host': 'api.twilio.com'},
190+
body='foobar'
191+
)
192+
193+
expected_hash = '4dc9b67bed579647914587b0e22a1c65c1641d8674797cd82de65e766cce5f80'
194+
195+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
196+
197+
actual_payload = jwt._generate_payload()
198+
self.assertEqual('authorization;host', actual_payload['hrh'])
199+
self.assertEqual(expected_hash, actual_payload['rqh'])
200+
201+
def test_jwt_payload(self):
202+
vp = ValidationPayload(
203+
method='GET',
204+
url='/Messages',
205+
query_string='PageSize=5&Limit=10',
206+
signed_headers=['authorization', 'host'],
207+
all_headers={'authorization': 'foobar', 'host': 'api.twilio.com'},
208+
body='foobar'
209+
)
210+
expected_hash = '4dc9b67bed579647914587b0e22a1c65c1641d8674797cd82de65e766cce5f80'
211+
212+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', 'secret', vp)
213+
214+
self.assertDictContainsSubset({
215+
'hrh': 'authorization;host',
216+
'rqh': expected_hash,
217+
'iss': 'SK123',
218+
'sub': 'AC123',
219+
}, jwt.payload)
220+
self.assertGreaterEqual(jwt.payload['exp'], time.time(), 'JWT exp is before now')
221+
self.assertLessEqual(jwt.payload['exp'], time.time() + 501, 'JWT exp is after now + 5mins')
222+
self.assertDictEqual({
223+
'alg': 'RS256',
224+
'typ': 'JWT',
225+
'cty': 'twilio-pkrv;v=1',
226+
'kid': 'CR123'
227+
}, jwt.headers)
228+
229+
def test_jwt_signing(self):
230+
vp = ValidationPayload(
231+
method='GET',
232+
url='/Messages',
233+
query_string='PageSize=5&Limit=10',
234+
signed_headers=['authorization', 'host'],
235+
all_headers={'authorization': 'foobar', 'host': 'api.twilio.com'},
236+
body='foobar'
237+
)
238+
expected_hash = '4dc9b67bed579647914587b0e22a1c65c1641d8674797cd82de65e766cce5f80'
239+
240+
private_key = rsa.generate_private_key(
241+
public_exponent=65537,
242+
key_size=2048,
243+
backend=default_backend()
244+
)
245+
public_key = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.PKCS1)
246+
private_key = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
247+
248+
jwt = ClientValidationJwt('AC123', 'SK123', 'CR123', private_key, vp)
249+
decoded = Jwt.from_jwt(jwt.to_jwt(), public_key)
250+
251+
self.assertDictContainsSubset({
252+
'hrh': 'authorization;host',
253+
'rqh': expected_hash,
254+
'iss': 'SK123',
255+
'sub': 'AC123',
256+
}, decoded.payload)
257+
self.assertGreaterEqual(decoded.payload['exp'], time.time(), 'JWT exp is before now')
258+
self.assertLessEqual(decoded.payload['exp'], time.time() + 501, 'JWT exp is after now + 5m')
259+
self.assertDictEqual({
260+
'alg': 'RS256',
261+
'typ': 'JWT',
262+
'cty': 'twilio-pkrv;v=1',
263+
'kid': 'CR123'
264+
}, decoded.headers)
265+
266+

twilio/http/http_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66

77
class TwilioHttpClient(HttpClient):
8+
"""General purpose HTTP Client for interacting with the Twilio API"""
89
def request(self, method, url, params=None, data=None, headers=None, auth=None, timeout=None,
910
allow_redirects=False):
1011
"""
11-
General purpose HTTP client to make an HTTP request
12+
Make an HTTP Request with parameters provided.
1213
1314
:param str method: The HTTP method to use
1415
:param str url: The URL to request
@@ -18,11 +19,10 @@ def request(self, method, url, params=None, data=None, headers=None, auth=None,
1819
:param tuple auth: Basic Auth arguments
1920
:param float timeout: Socket/Read timeout for the request
2021
:param boolean allow_redirects: Whether or not to allow redirects
22+
See the requests documentation for explanation of all these parameters
2123
2224
:return: An http response
2325
:rtype: A :class:`Response <twilio.rest.http.response.Response>` object
24-
25-
See the requests documentation for explanation of all these parameters
2626
"""
2727
session = Session()
2828
session.verify = get_cert_file()

twilio/http/validation_client.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from collections import namedtuple
2+
3+
from requests import Request, Session
4+
5+
from twilio.http import HttpClient, get_cert_file
6+
from twilio.http.response import Response
7+
from twilio.jwt.validation import ClientValidationJwt
8+
9+
10+
ValidationPayload = namedtuple('ValidationPayload', ['method', 'url', 'query_string', 'all_headers',
11+
'signed_headers', 'body'])
12+
13+
14+
class ValidationClient(HttpClient):
15+
__SIGNED_HEADERS = ['authorization', 'host']
16+
17+
def __init__(self, account_sid, api_key_sid, credential_sid, private_key):
18+
"""
19+
Build a ValidationClient which signs requests with private_key and allows Twilio to
20+
validate request has not been tampered with.
21+
22+
:param str account_sid: A Twilio Account Sid starting with 'AC'
23+
:param str api_key_sid: A Twilio API Key Sid starting with 'SK'
24+
:param str credential_sid: A Credential Sid starting with 'CR',
25+
corresponds to public key Twilio will use to verify the JWT.
26+
:param str private_key: The private key used to sign the Client Validation JWT.
27+
"""
28+
self.account_sid = account_sid
29+
self.credential_sid = credential_sid
30+
self.api_key_sid = api_key_sid
31+
self.private_key = private_key
32+
33+
def request(self, method, url, params=None, data=None, headers=None, auth=None, timeout=None,
34+
allow_redirects=False):
35+
"""
36+
Make a signed HTTP Request
37+
38+
:param str method: The HTTP method to use
39+
:param str url: The URL to request
40+
:param dict params: Query parameters to append to the URL
41+
:param dict data: Parameters to go in the body of the HTTP request
42+
:param dict headers: HTTP Headers to send with the request
43+
:param tuple auth: Basic Auth arguments
44+
:param float timeout: Socket/Read timeout for the request
45+
:param boolean allow_redirects: Whether or not to allow redirects
46+
See the requests documentation for explanation of all these parameters
47+
48+
:return: An http response
49+
:rtype: A :class:`Response <twilio.rest.http.response.Response>` object
50+
"""
51+
session = Session()
52+
session.verify = get_cert_file()
53+
54+
request = Request(method.upper(), url, params=params, data=data, headers=headers, auth=auth)
55+
prepared_request = session.prepare_request(request)
56+
57+
validation_payload = self.__build_validation_payload(prepared_request)
58+
jwt = ClientValidationJwt(self.account_sid, self.api_key_sid, self.credential_sid,
59+
self.private_key, validation_payload)
60+
prepared_request.headers['Twilio-Client-Validation'] = jwt.to_jwt()
61+
62+
response = session.send(
63+
prepared_request,
64+
allow_redirects=allow_redirects,
65+
timeout=timeout,
66+
)
67+
68+
return Response(int(response.status_code), response.content.decode('utf-8'))
69+
70+
def __build_validation_payload(self, request):
71+
"""
72+
Extract relevant information from request to build a ClientValidationJWT
73+
:param PreparedRequest request: request we will extract information from.
74+
:return: ValidationPayload
75+
"""
76+
try:
77+
url, query_string = request.url.split('?', 1)
78+
except ValueError:
79+
url = request.url
80+
query_string = ''
81+
82+
return ValidationPayload(
83+
method=request.method,
84+
url=url,
85+
query_string=query_string,
86+
all_headers=request.headers,
87+
signed_headers=ValidationClient.__SIGNED_HEADERS,
88+
body=request.body
89+
)

0 commit comments

Comments
 (0)