Skip to content

Commit 5d7868c

Browse files
author
Landon Gilbert-Bland
committed
Add support for JWT in query string (#117)
1 parent ec0fc37 commit 5d7868c

11 files changed

+304
-77
lines changed

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ Flask-JWT-Extended's Documentation
2222
options
2323
blacklist_and_token_revoking
2424
tokens_in_cookies
25+
tokens_in_query_string
2526
api

docs/options.rst

+12-1
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'`` or ``'cookies'``. You can pass
19+
options are ``'headers'``, ``'cookies'``, or ``'query_string'``. 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
@@ -56,6 +56,17 @@ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use headers.
5656
================================= =========================================
5757

5858

59+
Query String Options:
60+
~~~~~~~~~~~~~~~~~~~~~
61+
These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use query strings.
62+
63+
.. tabularcolumns:: |p{6.5cm}|p{8.5cm}|
64+
65+
================================= =========================================
66+
``JWT_QUERY_STRING_NAME`` What query paramater name to look for a JWT in a request. Defaults to ``'jwt'``
67+
================================= =========================================
68+
69+
5970
Cookie Options:
6071
~~~~~~~~~~~~~~~
6172
These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies.

docs/tokens_in_query_string.rst

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
JWT in Query String
2+
===================
3+
4+
You can also pass the token in as a paramater in the query string instead of as
5+
a header or a cookie (ex: /protected?jwt=<TOKEN>). However, in almost all
6+
cases it is recomended that you do not do this, as it comes with some security
7+
issues. If you perform a GET request with a JWT in the query param, it is
8+
possible that the browser will save the URL, which could lead to a leaked
9+
token. It is also very likely that your backend (such as nginx or uwsgi) could
10+
log the full url paths, which is obviously not ideal from a security standpoint.
11+
12+
If you do decide to use JWTs in query paramaters, here is an example of how
13+
it might look:
14+
15+
.. literalinclude:: ../examples/jwt_in_query_string.py

examples/jwt_in_query_string.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from flask import Flask, jsonify, request
2+
3+
from flask_jwt_extended import (
4+
JWTManager, jwt_required, create_access_token,
5+
)
6+
7+
# IMPORTANT NOTE:
8+
# In most cases this is not recommended! It can lead some some
9+
# security issues, such as:
10+
# - The browser saving GET request urls in it's history that
11+
# has a JWT in the query string
12+
# - The backend server logging JWTs that are in the url
13+
#
14+
# If possible, you should use headers instead!
15+
16+
app = Flask(__name__)
17+
app.config['JWT_TOKEN_LOCATION'] = ['query_string']
18+
app.config['JWT_SECRET_KEY'] = 'super-secret' # Change this!
19+
20+
jwt = JWTManager(app)
21+
22+
23+
@app.route('/login', methods=['POST'])
24+
def login():
25+
username = request.json.get('username', None)
26+
password = request.json.get('password', None)
27+
if username != 'test' or password != 'test':
28+
return jsonify({"msg": "Bad username or password"}), 401
29+
30+
access_token = create_access_token(identity=username)
31+
return jsonify(access_token=access_token)
32+
33+
34+
# The default query paramater where the JWT is looked for is `jwt`,
35+
# and can be changed with the JWT_QUERY_STRING_NAME option. Making
36+
# a request to this endpoint would look like:
37+
# /protected?jwt=<ACCESS_TOKEN>
38+
@app.route('/protected', methods=['GET'])
39+
@jwt_required
40+
def protected():
41+
return jsonify(foo='bar')
42+
43+
if __name__ == '__main__':
44+
app.run()

flask_jwt_extended/config.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ def token_location(self):
4444
locations = current_app.config['JWT_TOKEN_LOCATION']
4545
if not isinstance(locations, list):
4646
locations = [locations]
47+
if not locations:
48+
raise RuntimeError('JWT_TOKEN_LOCATION must contain at least one '
49+
'of "headers", "cookies", or "query_string"')
4750
for location in locations:
48-
if location not in ('headers', 'cookies'):
51+
if location not in ('headers', 'cookies', 'query_string'):
4952
raise RuntimeError('JWT_TOKEN_LOCATION can only contain '
50-
'"headers" and/or "cookies"')
53+
'"headers", "cookies", or "query_string"')
5154
return locations
5255

5356
@property
@@ -58,6 +61,10 @@ def jwt_in_cookies(self):
5861
def jwt_in_headers(self):
5962
return 'headers' in self.token_location
6063

64+
@property
65+
def jwt_in_query_string(self):
66+
return 'query_string' in self.token_location
67+
6168
@property
6269
def header_name(self):
6370
name = current_app.config['JWT_HEADER_NAME']
@@ -69,6 +76,10 @@ def header_name(self):
6976
def header_type(self):
7077
return current_app.config['JWT_HEADER_TYPE']
7178

79+
@property
80+
def query_string_name(self):
81+
return current_app.config['JWT_QUERY_STRING_NAME']
82+
7283
@property
7384
def access_cookie_name(self):
7485
return current_app.config['JWT_ACCESS_COOKIE_NAME']

flask_jwt_extended/jwt_manager.py

+3
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ def _set_default_configuration_options(app):
138138
app.config.setdefault('JWT_HEADER_NAME', 'Authorization')
139139
app.config.setdefault('JWT_HEADER_TYPE', 'Bearer')
140140

141+
# Options for JWTs then the TOKEN_LOCATION is query_string
142+
app.config.setdefault('JWT_QUERY_STRING_NAME', 'jwt')
143+
141144
# Option for JWTs when the TOKEN_LOCATION is cookies
142145
app.config.setdefault('JWT_ACCESS_COOKIE_NAME', 'access_token_cookie')
143146
app.config.setdefault('JWT_REFRESH_COOKIE_NAME', 'refresh_token_cookie')

flask_jwt_extended/view_decorators.py

+43-18
Original file line numberDiff line numberDiff line change
@@ -170,27 +170,52 @@ def _decode_jwt_from_cookies(request_type):
170170
return decode_token(encoded_token, csrf_value=csrf_value)
171171

172172

173+
def _decode_jwt_from_query_string():
174+
query_param = config.query_string_name
175+
encoded_token = request.args.get(query_param)
176+
if not encoded_token:
177+
raise NoAuthorizationError('Missing "{}" query paramater'.format(query_param))
178+
179+
return decode_token(encoded_token)
180+
181+
173182
def _decode_jwt_from_request(request_type):
174-
# We have three cases here, having jwts in both cookies and headers is
175-
# valid, or the jwt can only be saved in one of cookies or headers. Check
176-
# all cases here.
177-
if config.jwt_in_cookies and config.jwt_in_headers:
183+
# All the places we can get a JWT from in this request
184+
decode_functions = []
185+
if config.jwt_in_cookies:
186+
decode_functions.append(lambda: _decode_jwt_from_cookies(request_type))
187+
if config.jwt_in_query_string:
188+
decode_functions.append(_decode_jwt_from_query_string)
189+
if config.jwt_in_headers:
190+
decode_functions.append(_decode_jwt_from_headers)
191+
192+
# Try to find the token from one of these locations. It only needs to exist
193+
# in one place to be valid (not every location).
194+
errors = []
195+
decoded_token = None
196+
for decode_function in decode_functions:
178197
try:
179-
decoded_token = _decode_jwt_from_cookies(request_type)
180-
except NoAuthorizationError:
181-
try:
182-
decoded_token = _decode_jwt_from_headers()
183-
except NoAuthorizationError:
184-
raise NoAuthorizationError("Missing JWT in headers and cookies")
185-
elif config.jwt_in_headers:
186-
decoded_token = _decode_jwt_from_headers()
187-
else:
188-
decoded_token = _decode_jwt_from_cookies(request_type)
198+
decoded_token = decode_function()
199+
break
200+
except NoAuthorizationError as e:
201+
errors.append(str(e))
202+
203+
# Do some work to make a helpful and human readable error message if no
204+
# token was found in any of the expected locations.
205+
if not decoded_token:
206+
token_locations = config.token_location
207+
multiple_jwt_locations = len(token_locations) != 1
208+
209+
if multiple_jwt_locations:
210+
err_msg = "Missing JWT in {start_locs} or {end_locs} ({details})".format(
211+
start_locs=", ".join(token_locations[:-1]),
212+
end_locs=token_locations[-1],
213+
details= "; ".join(errors)
214+
)
215+
raise NoAuthorizationError(err_msg)
216+
else:
217+
raise NoAuthorizationError(errors[0])
189218

190-
# Make sure the type of token we received matches the request type we expect
191219
verify_token_type(decoded_token, expected_type=request_type)
192-
193-
# If blacklisting is enabled, see if this token has been revoked
194220
verify_token_not_blacklisted(decoded_token, request_type)
195-
196221
return decoded_token

tests/test_config.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ def app():
1919
def test_default_configs(app):
2020
with app.test_request_context():
2121
assert config.token_location == ['headers']
22+
assert config.jwt_in_query_string is False
2223
assert config.jwt_in_cookies is False
2324
assert config.jwt_in_headers is True
25+
2426
assert config.header_name == 'Authorization'
2527
assert config.header_type == 'Bearer'
2628

29+
assert config.query_string_name == 'jwt'
30+
2731
assert config.access_cookie_name == 'access_token_cookie'
2832
assert config.refresh_cookie_name == 'refresh_token_cookie'
2933
assert config.access_cookie_path == '/'
@@ -61,10 +65,12 @@ def test_default_configs(app):
6165

6266

6367
def test_override_configs(app):
64-
app.config['JWT_TOKEN_LOCATION'] = ['cookies']
68+
app.config['JWT_TOKEN_LOCATION'] = ['cookies', 'query_string']
6569
app.config['JWT_HEADER_NAME'] = 'TestHeader'
6670
app.config['JWT_HEADER_TYPE'] = 'TestType'
6771

72+
app.config['JWT_QUERY_STRING_NAME'] = 'banana'
73+
6874
app.config['JWT_ACCESS_COOKIE_NAME'] = 'new_access_cookie'
6975
app.config['JWT_REFRESH_COOKIE_NAME'] = 'new_refresh_cookie'
7076
app.config['JWT_ACCESS_COOKIE_PATH'] = '/access/path'
@@ -100,12 +106,15 @@ class CustomJSONEncoder(JSONEncoder):
100106
app.json_encoder = CustomJSONEncoder
101107

102108
with app.test_request_context():
103-
assert config.token_location == ['cookies']
109+
assert config.token_location == ['cookies', 'query_string']
110+
assert config.jwt_in_query_string is True
104111
assert config.jwt_in_cookies is True
105112
assert config.jwt_in_headers is False
106113
assert config.header_name == 'TestHeader'
107114
assert config.header_type == 'TestType'
108115

116+
assert config.query_string_name == 'banana'
117+
109118
assert config.access_cookie_name == 'new_access_cookie'
110119
assert config.refresh_cookie_name == 'new_refresh_cookie'
111120
assert config.access_cookie_path == '/access/path'
@@ -208,6 +217,10 @@ def test_default_with_asymmetric_secret_key(app):
208217
# noinspection PyStatementEffect
209218
def test_invalid_config_options(app):
210219
with app.test_request_context():
220+
app.config['JWT_TOKEN_LOCATION'] = []
221+
with pytest.raises(RuntimeError):
222+
config.token_location
223+
211224
app.config['JWT_TOKEN_LOCATION'] = 'banana'
212225
with pytest.raises(RuntimeError):
213226
config.token_location

tests/test_headers_and_cookies.py

-54
This file was deleted.

0 commit comments

Comments
 (0)