diff --git a/docs/options.rst b/docs/options.rst index b1b879d0..89ccf9bd 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -80,6 +80,8 @@ These are only applicable if ``JWT_TOKEN_LOCATION`` is set to use cookies. ``JWT_SESSION_COOKIE`` If the cookies should be session cookies (deleted when the browser is closed) or persistent cookies (never expire). Defaults to ``True`` (session cookies). +``JWT_COOKIE_SAMESITE`` If the cookies should be sent in a cross-site browsing context. + Defaults to ``None``, which means cookies are always sent. ``JWT_COOKIE_CSRF_PROTECT`` Enable/disable CSRF protection when using cookies. Defaults to ``True``. ================================= ========================================= diff --git a/flask_jwt_extended/config.py b/flask_jwt_extended/config.py index 0c7e21c6..d55284b2 100644 --- a/flask_jwt_extended/config.py +++ b/flask_jwt_extended/config.py @@ -97,6 +97,10 @@ def cookie_domain(self): def session_cookie(self): return current_app.config['JWT_SESSION_COOKIE'] + @property + def cookie_samesite(self): + return current_app.config['JWT_COOKIE_SAMESITE'] + @property def csrf_protect(self): return self.jwt_in_cookies and current_app.config['JWT_COOKIE_CSRF_PROTECT'] diff --git a/flask_jwt_extended/jwt_manager.py b/flask_jwt_extended/jwt_manager.py index 76ba02f3..6915313e 100644 --- a/flask_jwt_extended/jwt_manager.py +++ b/flask_jwt_extended/jwt_manager.py @@ -150,6 +150,7 @@ def _set_default_configuration_options(app): app.config.setdefault('JWT_COOKIE_SECURE', False) app.config.setdefault('JWT_COOKIE_DOMAIN', None) app.config.setdefault('JWT_SESSION_COOKIE', True) + app.config.setdefault('JWT_COOKIE_SAMESITE', None) # Options for using double submit csrf protection app.config.setdefault('JWT_COOKIE_CSRF_PROTECT', True) diff --git a/flask_jwt_extended/utils.py b/flask_jwt_extended/utils.py index 31c8d7a7..390591b9 100644 --- a/flask_jwt_extended/utils.py +++ b/flask_jwt_extended/utils.py @@ -194,7 +194,8 @@ def set_access_cookies(response, encoded_access_token, max_age=None): secure=config.cookie_secure, httponly=True, domain=config.cookie_domain, - path=config.access_cookie_path) + path=config.access_cookie_path, + samesite=config.cookie_samesite) # If enabled, set the csrf double submit access cookie if config.csrf_protect and config.csrf_in_cookies: @@ -204,7 +205,8 @@ def set_access_cookies(response, encoded_access_token, max_age=None): secure=config.cookie_secure, httponly=False, domain=config.cookie_domain, - path=config.access_csrf_cookie_path) + path=config.access_csrf_cookie_path, + samesite=config.cookie_samesite) def set_refresh_cookies(response, encoded_refresh_token, max_age=None): @@ -232,7 +234,8 @@ def set_refresh_cookies(response, encoded_refresh_token, max_age=None): secure=config.cookie_secure, httponly=True, domain=config.cookie_domain, - path=config.refresh_cookie_path) + path=config.refresh_cookie_path, + samesite=config.cookie_samesite) # If enabled, set the csrf double submit refresh cookie if config.csrf_protect and config.csrf_in_cookies: @@ -242,7 +245,8 @@ def set_refresh_cookies(response, encoded_refresh_token, max_age=None): secure=config.cookie_secure, httponly=False, domain=config.cookie_domain, - path=config.refresh_csrf_cookie_path) + path=config.refresh_csrf_cookie_path, + samesite=config.cookie_samesite) def unset_jwt_cookies(response): @@ -262,14 +266,16 @@ def unset_jwt_cookies(response): secure=config.cookie_secure, httponly=True, domain=config.cookie_domain, - path=config.refresh_cookie_path) + path=config.refresh_cookie_path, + samesite=config.cookie_samesite) response.set_cookie(config.access_cookie_name, value='', expires=0, secure=config.cookie_secure, httponly=True, domain=config.cookie_domain, - path=config.access_cookie_path) + path=config.access_cookie_path, + samesite=config.cookie_samesite) if config.csrf_protect and config.csrf_in_cookies: response.set_cookie(config.refresh_csrf_cookie_name, @@ -278,11 +284,13 @@ def unset_jwt_cookies(response): secure=config.cookie_secure, httponly=False, domain=config.cookie_domain, - path=config.refresh_csrf_cookie_path) + path=config.refresh_csrf_cookie_path, + samesite=config.cookie_samesite) response.set_cookie(config.access_csrf_cookie_name, value='', expires=0, secure=config.cookie_secure, httponly=False, domain=config.cookie_domain, - path=config.access_csrf_cookie_path) + path=config.access_csrf_cookie_path, + samesite=config.cookie_samesite) diff --git a/tests/test_config.py b/tests/test_config.py index dd37c651..b3149022 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -30,6 +30,7 @@ def test_default_configs(app): assert config.cookie_secure is False assert config.cookie_domain is None assert config.session_cookie is True + assert config.cookie_samesite is None assert config.csrf_protect is False assert config.csrf_request_methods == ['POST', 'PUT', 'PATCH', 'DELETE'] @@ -68,6 +69,7 @@ def test_override_configs(app): app.config['JWT_COOKIE_SECURE'] = True app.config['JWT_COOKIE_DOMAIN'] = ".example.com" app.config['JWT_SESSION_COOKIE'] = False + app.config['JWT_COOKIE_SAMESITE'] = "Strict" app.config['JWT_COOKIE_CSRF_PROTECT'] = True app.config['JWT_CSRF_METHODS'] = ['GET'] @@ -103,6 +105,7 @@ def test_override_configs(app): assert config.cookie_secure is True assert config.cookie_domain == ".example.com" assert config.session_cookie is False + assert config.cookie_samesite == "Strict" assert config.csrf_protect is True assert config.csrf_request_methods == ['GET'] diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 8d4eac09..30b4964a 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -1,9 +1,5 @@ import pytest from flask import Flask, jsonify, json -try: - from http.cookies import SimpleCookie -except ImportError: - from Cookie import SimpleCookie from flask_jwt_extended import ( jwt_required, JWTManager, jwt_refresh_token_required, create_access_token, @@ -11,17 +7,18 @@ unset_jwt_cookies ) - def _get_cookie_from_response(response, cookie_name): cookie_headers = response.headers.getlist('Set-Cookie') for header in cookie_headers: - cookie = SimpleCookie() - cookie.load(header) - if cookie_name in cookie: - return cookie[cookie_name] + attributes = header.split(';') + if cookie_name in attributes[0]: + cookie = {} + for attr in attributes: + split = attr.split('=') + cookie[split[0].strip().lower()] = split[1] if len(split) > 1 else True + return cookie return None - @pytest.fixture(scope='function') def app(): app = Flask(__name__) @@ -111,7 +108,7 @@ def test_default_access_csrf_protection(app, options): # Get the jwt cookies and csrf double submit tokens response = test_client.get(auth_url) - csrf_token = _get_cookie_from_response(response, csrf_cookie_name).value + csrf_token = _get_cookie_from_response(response, csrf_cookie_name)[csrf_cookie_name] # Test you cannot post without the additional csrf protection response = test_client.post(post_url) @@ -173,7 +170,7 @@ def test_csrf_with_custom_header_names(app, options): # Get the jwt cookies and csrf double submit tokens response = test_client.get(auth_url) - csrf_token = _get_cookie_from_response(response, csrf_cookie_name).value + csrf_token = _get_cookie_from_response(response, csrf_cookie_name)[csrf_cookie_name] # Test that you can post with the csrf double submit value csrf_headers = {'FOO': csrf_token} @@ -194,7 +191,7 @@ def test_custom_csrf_methods(app, options): # Get the jwt cookies and csrf double submit tokens response = test_client.get(auth_url) - csrf_token = _get_cookie_from_response(response, csrf_cookie_name).value + csrf_token = _get_cookie_from_response(response, csrf_cookie_name)[csrf_cookie_name] # Insure we can now do posts without csrf response = test_client.post(post_url) @@ -240,11 +237,13 @@ def test_default_cookie_options(app): assert access_cookie is not None assert access_cookie['path'] == '/' assert access_cookie['httponly'] is True + assert 'samesite' not in access_cookie access_csrf_cookie = _get_cookie_from_response(response, 'csrf_access_token') assert access_csrf_cookie is not None assert access_csrf_cookie['path'] == '/' - assert access_csrf_cookie['httponly'] == '' + assert 'httponly' not in access_csrf_cookie + assert 'samesite' not in access_csrf_cookie # Test the default refresh cookies response = test_client.get('/refresh_token') @@ -255,11 +254,13 @@ def test_default_cookie_options(app): assert refresh_cookie is not None assert refresh_cookie['path'] == '/' assert refresh_cookie['httponly'] is True + assert 'samesite' not in refresh_cookie refresh_csrf_cookie = _get_cookie_from_response(response, 'csrf_refresh_token') assert refresh_csrf_cookie is not None assert refresh_csrf_cookie['path'] == '/' - assert refresh_csrf_cookie['httponly'] == '' + assert 'httponly' not in refresh_csrf_cookie + assert 'samesite' not in refresh_csrf_cookie def test_custom_cookie_options(app): @@ -268,6 +269,7 @@ def test_custom_cookie_options(app): app.config['JWT_COOKIE_SECURE'] = True app.config['JWT_COOKIE_DOMAIN'] = 'test.com' app.config['JWT_SESSION_COOKIE'] = False + app.config['JWT_COOKIE_SAMESITE'] = 'Strict' # Test access cookies with changed options response = test_client.get('/access_token') @@ -281,6 +283,7 @@ def test_custom_cookie_options(app): assert access_cookie['expires'] != '' assert access_cookie['httponly'] is True assert access_cookie['secure'] is True + assert access_cookie['samesite'] == 'Strict' access_csrf_cookie = _get_cookie_from_response(response, 'csrf_access_token') assert access_csrf_cookie is not None @@ -288,6 +291,7 @@ def test_custom_cookie_options(app): assert access_csrf_cookie['secure'] is True assert access_csrf_cookie['domain'] == 'test.com' assert access_csrf_cookie['expires'] != '' + assert access_csrf_cookie['samesite'] == 'Strict' # Test refresh cookies with changed options response = test_client.get('/refresh_token') @@ -301,6 +305,7 @@ def test_custom_cookie_options(app): assert refresh_cookie['httponly'] is True assert refresh_cookie['secure'] is True assert refresh_cookie['expires'] != '' + assert refresh_cookie['samesite'] == 'Strict' refresh_csrf_cookie = _get_cookie_from_response(response, 'csrf_refresh_token') assert refresh_csrf_cookie is not None @@ -308,6 +313,7 @@ def test_custom_cookie_options(app): assert refresh_csrf_cookie['secure'] is True assert refresh_csrf_cookie['domain'] == 'test.com' assert refresh_csrf_cookie['expires'] != '' + assert refresh_csrf_cookie['samesite'] == 'Strict' def test_custom_cookie_names_and_paths(app):