Skip to content

Commit e5e37d7

Browse files
authored
Merge pull request #173 from luord/json-lookup
Added a fourth lookup within the json body
2 parents 131c6f4 + 5adb219 commit e5e37d7

File tree

10 files changed

+249
-8
lines changed

10 files changed

+249
-8
lines changed

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ Flask-JWT-Extended's Documentation
2424
blacklist_and_token_revoking
2525
tokens_in_cookies
2626
tokens_in_query_string
27+
tokens_in_json_body
2728
api

docs/options.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ General Options:
1616

1717
================================= =========================================
1818
``JWT_TOKEN_LOCATION`` Where to look for a JWT when processing a request. The
19-
options are ``'headers'``, ``'cookies'``, or ``'query_string'``. You can pass
19+
options are ``'headers'``, ``'cookies'``, ``'query_string'``, or ``'json'``. You can pass
2020
in a list to check more then one location, such as: ``['headers', 'cookies']``.
2121
Defaults to ``'headers'``
2222
``JWT_ACCESS_TOKEN_EXPIRES`` How long an access token should live before it expires. This
@@ -101,6 +101,19 @@ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies.
101101
``JWT_COOKIE_CSRF_PROTECT`` Enable/disable CSRF protection when using cookies. Defaults to ``True``.
102102
================================= =========================================
103103

104+
105+
Json Body Options:
106+
~~~~~~~~~~~~~~~~~~~~~
107+
These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use json data.
108+
109+
.. tabularcolumns:: |p{6.5cm}|p{8.5cm}|
110+
111+
================================= =========================================
112+
``JWT_JSON_KEY`` Key to look for in the body of an `application/json` request. Defaults to ``'access_token'``
113+
``JWT_REFRESH_JSON_KEY`` Key to look for the refresh token in an `application/json` request. Defaults to ``'refresh_token'``
114+
================================= =========================================
115+
116+
104117
Cross Site Request Forgery Options:
105118
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
106119
These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies and

docs/tokens_in_json_body.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
JWT in JSON Body
2+
================
3+
4+
You can also pass the token as an attribute in the body of an `application/json` request.
5+
However, since the body is meaningless in a `GET` request, this is mostly useful for
6+
protecting routes that only accept `POST`, `PATCH`, or `DELETE` methods.
7+
8+
That is to say, the `GET` method will become essentially unauthorized in any protected route
9+
if you only use this lookup method.
10+
11+
If you decide to use JWTs in the request body, here is an example of how it might look:
12+
13+
.. literalinclude:: ../examples/jwt_in_json.py

examples/jwt_in_json.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from flask import Flask, jsonify, request
2+
3+
from flask_jwt_extended import (
4+
JWTManager, jwt_required, create_access_token,
5+
)
6+
7+
app = Flask(__name__)
8+
9+
# IMPORTANT: Body is meaningless in GET requests, so using json
10+
# as the only lookup method means that the GET method will become
11+
# unauthorized in any protected route, as there's no body to look for.
12+
13+
app.config['JWT_TOKEN_LOCATION'] = ['json']
14+
app.config['JWT_SECRET_KEY'] = 'super-secret' # Change this!
15+
16+
jwt = JWTManager(app)
17+
18+
19+
@app.route('/login', methods=['POST'])
20+
def login():
21+
username = request.json.get('username', None)
22+
password = request.json.get('password', None)
23+
if username != 'test' or password != 'test':
24+
return jsonify({"msg": "Bad username or password"}), 401
25+
26+
access_token = create_access_token(identity=username)
27+
return jsonify(access_token=access_token)
28+
29+
30+
# The default attribute name where the JWT is looked for is `access_token`,
31+
# and can be changed with the JWT_JSON_KEY option.
32+
# Notice how the route is unreachable with GET requests.
33+
@app.route('/protected', methods=['GET', 'POST'])
34+
@jwt_required
35+
def protected():
36+
return jsonify(foo='bar')
37+
38+
if __name__ == '__main__':
39+
app.run()

flask_jwt_extended/config.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ def token_location(self):
4646
locations = [locations]
4747
if not locations:
4848
raise RuntimeError('JWT_TOKEN_LOCATION must contain at least one '
49-
'of "headers", "cookies", or "query_string"')
49+
'of "headers", "cookies", "query_string", or "json"')
5050
for location in locations:
51-
if location not in ('headers', 'cookies', 'query_string'):
51+
if location not in ('headers', 'cookies', 'query_string', 'json'):
5252
raise RuntimeError('JWT_TOKEN_LOCATION can only contain '
53-
'"headers", "cookies", or "query_string"')
53+
'"headers", "cookies", "query_string", or "json"')
5454
return locations
5555

5656
@property
@@ -65,6 +65,10 @@ def jwt_in_headers(self):
6565
def jwt_in_query_string(self):
6666
return 'query_string' in self.token_location
6767

68+
@property
69+
def jwt_in_json(self):
70+
return 'json' in self.token_location
71+
6872
@property
6973
def header_name(self):
7074
name = current_app.config['JWT_HEADER_NAME']
@@ -112,6 +116,14 @@ def session_cookie(self):
112116
def cookie_samesite(self):
113117
return current_app.config['JWT_COOKIE_SAMESITE']
114118

119+
@property
120+
def json_key(self):
121+
return current_app.config['JWT_JSON_KEY']
122+
123+
@property
124+
def refresh_json_key(self):
125+
return current_app.config['JWT_REFRESH_JSON_KEY']
126+
115127
@property
116128
def csrf_protect(self):
117129
return self.jwt_in_cookies and current_app.config['JWT_COOKIE_CSRF_PROTECT']

flask_jwt_extended/jwt_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ def _set_default_configuration_options(app):
151151
app.config.setdefault('JWT_SESSION_COOKIE', True)
152152
app.config.setdefault('JWT_COOKIE_SAMESITE', None)
153153

154+
# Option for JWTs when the TOKEN_LOCATION is json
155+
app.config.setdefault('JWT_JSON_KEY', 'access_token')
156+
app.config.setdefault('JWT_REFRESH_JSON_KEY', 'refresh_token')
157+
154158
# Options for using double submit csrf protection
155159
app.config.setdefault('JWT_COOKIE_CSRF_PROTECT', True)
156160
app.config.setdefault('JWT_CSRF_METHODS', ['POST', 'PUT', 'PATCH', 'DELETE'])

flask_jwt_extended/view_decorators.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from datetime import datetime
33
from calendar import timegm
44

5+
from werkzeug.exceptions import BadRequest
6+
57
from flask import request
68
try:
79
from flask import _app_ctx_stack as ctx_stack
@@ -220,6 +222,25 @@ def _decode_jwt_from_query_string():
220222
return decode_token(encoded_token)
221223

222224

225+
def _decode_jwt_from_json(request_type):
226+
if request.content_type != 'application/json':
227+
raise NoAuthorizationError('Invalid content-type. Must be application/json.')
228+
229+
if request_type == 'access':
230+
token_key = config.json_key
231+
else:
232+
token_key = config.refresh_json_key
233+
234+
try:
235+
encoded_token = request.json.get(token_key, None)
236+
if not encoded_token:
237+
raise BadRequest()
238+
except BadRequest:
239+
raise NoAuthorizationError('Missing "{}" key in json data.'.format(token_key))
240+
241+
return decode_token(encoded_token)
242+
243+
223244
def _decode_jwt_from_request(request_type):
224245
# All the places we can get a JWT from in this request
225246
decode_functions = []
@@ -229,6 +250,8 @@ def _decode_jwt_from_request(request_type):
229250
decode_functions.append(_decode_jwt_from_query_string)
230251
if config.jwt_in_headers:
231252
decode_functions.append(_decode_jwt_from_headers)
253+
if config.jwt_in_json:
254+
decode_functions.append(lambda: _decode_jwt_from_json(request_type))
232255

233256
# Try to find the token from one of these locations. It only needs to exist
234257
# in one place to be valid (not every location).

tests/test_config.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def test_default_configs(app):
2121
assert config.token_location == ['headers']
2222
assert config.jwt_in_query_string is False
2323
assert config.jwt_in_cookies is False
24+
assert config.jwt_in_json is False
2425
assert config.jwt_in_headers is True
2526

2627
assert config.header_name == 'Authorization'
@@ -37,6 +38,9 @@ def test_default_configs(app):
3738
assert config.session_cookie is True
3839
assert config.cookie_samesite is None
3940

41+
assert config.json_key == 'access_token'
42+
assert config.refresh_json_key == 'refresh_token'
43+
4044
assert config.csrf_protect is False
4145
assert config.csrf_request_methods == ['POST', 'PUT', 'PATCH', 'DELETE']
4246
assert config.csrf_in_cookies is True
@@ -69,9 +73,11 @@ def test_default_configs(app):
6973

7074

7175
def test_override_configs(app):
72-
app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'query_string']
76+
app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'query_string', 'json']
7377
app.config['JWT_HEADER_NAME'] = 'TestHeader'
7478
app.config['JWT_HEADER_TYPE'] = 'TestType'
79+
app.config['JWT_JSON_KEY'] = 'TestKey'
80+
app.config['JWT_REFRESH_JSON_KEY'] = 'TestRefreshKey'
7581

7682
app.config['JWT_QUERY_STRING_NAME'] = 'banana'
7783

@@ -114,12 +120,15 @@ class CustomJSONEncoder(JSONEncoder):
114120
app.json_encoder = CustomJSONEncoder
115121

116122
with app.test_request_context():
117-
assert config.token_location == ['cookies', 'query_string']
123+
assert config.token_location == ['cookies', 'query_string', 'json']
118124
assert config.jwt_in_query_string is True
119125
assert config.jwt_in_cookies is True
120126
assert config.jwt_in_headers is False
127+
assert config.jwt_in_json is True
121128
assert config.header_name == 'TestHeader'
122129
assert config.header_type == 'TestType'
130+
assert config.json_key == 'TestKey'
131+
assert config.refresh_json_key == 'TestRefreshKey'
123132

124133
assert config.query_string_name == 'banana'
125134

tests/test_json.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import pytest
2+
from flask import Flask, jsonify
3+
4+
from flask_jwt_extended import JWTManager, jwt_required, jwt_refresh_token_required, create_access_token, create_refresh_token
5+
from tests.utils import get_jwt_manager
6+
7+
8+
@pytest.fixture(scope='function')
9+
def app():
10+
app = Flask(__name__)
11+
app.config['JWT_SECRET_KEY'] = 'foobarbaz'
12+
app.config['JWT_TOKEN_LOCATION'] = 'json'
13+
JWTManager(app)
14+
15+
@app.route('/protected', methods=['POST'])
16+
@jwt_required
17+
def access_protected():
18+
return jsonify(foo='bar')
19+
20+
@app.route('/refresh', methods=['POST'])
21+
@jwt_refresh_token_required
22+
def refresh_protected():
23+
return jsonify(foo='bar')
24+
25+
return app
26+
27+
28+
def test_content_type(app):
29+
test_client = app.test_client()
30+
31+
with app.test_request_context():
32+
access_token = create_access_token('username')
33+
refresh_token = create_refresh_token('username')
34+
35+
data = {'access_token': access_token}
36+
response = test_client.post('/protected', data=data)
37+
38+
assert response.status_code == 401
39+
assert response.get_json() == {'msg': 'Invalid content-type. Must be application/json.'}
40+
41+
data = {'refresh_token': refresh_token}
42+
response = test_client.post('/refresh', data=data)
43+
44+
assert response.status_code == 401
45+
assert response.get_json() == {'msg': 'Invalid content-type. Must be application/json.'}
46+
47+
48+
def test_custom_body_key(app):
49+
app.config['JWT_JSON_KEY'] = 'Foo'
50+
app.config['JWT_REFRESH_JSON_KEY'] = 'Bar'
51+
test_client = app.test_client()
52+
53+
with app.test_request_context():
54+
access_token = create_access_token('username')
55+
refresh_token = create_refresh_token('username')
56+
57+
# Ensure 'default' keys no longer work
58+
data = {'access_token': access_token}
59+
response = test_client.post('/protected', json=data)
60+
assert response.status_code == 401
61+
assert response.get_json() == {'msg': 'Missing "Foo" key in json data.'}
62+
63+
64+
data = {'refresh_token': refresh_token}
65+
response = test_client.post('/refresh', json=data)
66+
assert response.status_code == 401
67+
assert response.get_json() == {'msg': 'Missing "Bar" key in json data.'}
68+
69+
# Ensure new keys do work
70+
data = {'Foo': access_token}
71+
response = test_client.post('/protected', json=data)
72+
assert response.status_code == 200
73+
assert response.get_json() == {'foo': 'bar'}
74+
75+
data = {'Bar': refresh_token}
76+
response = test_client.post('/refresh', json=data)
77+
assert response.status_code == 200
78+
assert response.get_json() == {'foo': 'bar'}
79+
80+
81+
def test_missing_keys(app):
82+
test_client = app.test_client()
83+
jwtM = get_jwt_manager(app)
84+
headers = {'content-type': 'application/json'}
85+
86+
# Ensure 'default' no json response
87+
response = test_client.post('/protected', headers=headers)
88+
assert response.status_code == 401
89+
assert response.get_json() == {'msg': 'Missing "access_token" key in json data.'}
90+
91+
# Test custom no json response
92+
@jwtM.unauthorized_loader
93+
def custom_response(err_str):
94+
return jsonify(foo='bar'), 201
95+
96+
response = test_client.post('/protected', headers=headers)
97+
assert response.status_code == 201
98+
assert response.get_json() == {'foo': "bar"}
99+
100+
def test_defaults(app):
101+
test_client = app.test_client()
102+
103+
with app.test_request_context():
104+
access_token = create_access_token('username')
105+
refresh_token = create_refresh_token('username')
106+
107+
data = {'access_token': access_token}
108+
response = test_client.post('/protected', json=data)
109+
assert response.status_code == 200
110+
assert response.get_json() == {'foo': 'bar'}
111+
112+
data = {'refresh_token': refresh_token}
113+
response = test_client.post('/refresh', json=data)
114+
assert response.status_code == 200
115+
assert response.get_json() == {'foo': 'bar'}

tests/test_multiple_token_locations.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
def app():
1111
app = Flask(__name__)
1212
app.config['JWT_SECRET_KEY'] = 'foobarbaz'
13-
app.config['JWT_TOKEN_LOCATION'] = ['headers', 'cookies', 'query_string']
13+
app.config['JWT_TOKEN_LOCATION'] = ['headers', 'cookies', 'query_string', 'json']
1414
JWTManager(app)
1515

1616
@app.route('/cookie_login', methods=['GET'])
@@ -20,7 +20,7 @@ def cookie_login():
2020
set_access_cookies(resp, access_token)
2121
return resp
2222

23-
@app.route('/protected', methods=['GET'])
23+
@app.route('/protected', methods=['GET', 'POST'])
2424
@jwt_required
2525
def access_protected():
2626
return jsonify(foo='bar')
@@ -58,6 +58,18 @@ def test_query_string_access(app):
5858
assert response.get_json() == {'foo': 'bar'}
5959

6060

61+
def test_json_access(app):
62+
test_client = app.test_client()
63+
64+
with app.test_request_context():
65+
access_token = create_access_token('username')
66+
67+
data = {'access_token': access_token}
68+
response = test_client.post('/protected', json=data)
69+
assert response.status_code == 200
70+
assert response.get_json() == {'foo': 'bar'}
71+
72+
6173
@pytest.mark.parametrize("options", [
6274
(['cookies', 'headers'], ('Missing JWT in cookies or headers (Missing cookie '
6375
'"access_token_cookie"; Missing Authorization Header)')),

0 commit comments

Comments
 (0)