Skip to content

Commit ff762d1

Browse files
authored
Support for storing refresh token in a cookie. (#1230)
This is best practice (and the default) when the application is browser based - an httponly cookie. Updated some docs that were missing some freshness and open redirect endpoint descriptions.
2 parents 6498873 + c644f68 commit ff762d1

10 files changed

Lines changed: 218 additions & 31 deletions

File tree

docs/configuration.rst

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,13 +2046,13 @@ Refresh Tokens
20462046
Default: ``"/refresh-token"``.
20472047
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_AGE
20482048
2049-
Specifies a timedelta used to compute the tokens expiration date.
2049+
Specifies a timedelta used to compute the refresh tokens expiration date.
20502050

20512051
Default: ``timedelta(days=90)``
20522052
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_IDLE
20532053
20542054
Specifies a timedelta used to compute the date the refresh token will
2055-
considered expired due to none use.
2055+
considered expired due to non-use.
20562056

20572057
Default: ``timedelta(days=7)``
20582058
.. py:data:: SECURITY_REFRESH_TOKEN_CLEANUP_EXPIRED
@@ -2067,6 +2067,21 @@ Refresh Tokens
20672067
whenever the user creates a new one (re-authenticates)
20682068

20692069
Default: ``False``
2070+
.. py:data:: SECURITY_REFRESH_COOKIE_NAME
2071+
2072+
If set then refresh tokens will be returned in a cookie rather than as
2073+
part of the JSON response. Upon logout, the cookie will be deleted.
2074+
2075+
Default: ``fs_refresh``
2076+
2077+
.. py:data:: SECURITY_REFRESH_TOKEN_COOKIE
2078+
2079+
A dict that defines the parameters required to
2080+
set the refresh token cookie.
2081+
The complete set of parameters is described in Flask's `set_cookie`_ documentation.
2082+
``"httponly"`` should ALWAYS be set.
2083+
2084+
Default: ``{"samesite": "Strict", "httponly": True, "secure": True}``
20702085

20712086
Feature Flags
20722087
-------------

docs/openapi.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ paths:
210210
summary: Log out current user
211211
description: >
212212
If a refresh-token is supplied, it will be revoked as part
213-
of logging the user out.
213+
of logging the user out. If SECURITY_REFRESH_TOKEN_COOKIE_NAME is configured,
214+
the refresh token will be deleted as part of logout.
214215
requestBody:
215216
required: false
216217
content:
@@ -2211,7 +2212,10 @@ paths:
22112212
$ref: "#/components/schemas/RefreshToken"
22122213
responses:
22132214
200:
2214-
description: Refresh token response
2215+
description: >
2216+
Refresh token response. If SECURITY_REFRESH_TOKEN_COOKIE_NAME is configured,
2217+
the response will NOT include the refresh token. The refresh token will be
2218+
stored in a cookie.
22152219
content:
22162220
application/json:
22172221
schema:

docs/patterns.rst

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,43 +89,40 @@ user's UserModel. By default that field is ``fs_uniquifier``. This means that
8989
if that field is changed (via :meth:`.UserDatastore.set_uniquifier`)
9090
then any existing authentication tokens will no longer be valid. This value is changed
9191
whenever a user changes their password. If this is not the desired behavior then you can add an additional
92-
attribute to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
93-
isolating password changes from authentication tokens. That attribute can be changed via
94-
:meth:`.UserDatastore.set_token_uniquifier`. This attribute should have ``unique=True``.
92+
field to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
93+
isolating password changes from authentication tokens. That field can be changed via
94+
:meth:`.UserDatastore.set_token_uniquifier`. This field should have ``unique=True``.
9595
Unlike ``fs_uniquifier``, it can be set to ``nullable`` - it will automatically be generated
96-
at first use if null.
96+
at first use if None.
9797

98-
Authentication tokens have 2 options for specifying expiry time :data:`SECURITY_TOKEN_MAX_AGE`
98+
Authentication tokens have 2 options for specifying expiry time: :data:`SECURITY_TOKEN_MAX_AGE`
9999
is applied to ALL authentication tokens. Each authentication token can itself have an embedded
100100
expiry value (settable via the :data:`SECURITY_TOKEN_EXPIRE_TIMESTAMP` callable).
101101

102102
Authentication tokens also convey freshness by recording the time the token was generated.
103103
This is used for endpoints protected with :func:`.auth_required` with a ``within``
104104
value set.
105105

106-
.. note::
107-
While every Flask-Security endpoint will accept an authentication token header,
108-
there are some endpoints that require session information (e.g. a session cookie).
109-
This includes entering in a second factor and handling of :ref:`CSRF<csrf_topic>`.
110-
As of release 5.5.0, authentication tokens by default carry freshness information.
111-
112106
The refresh token feature consists of:
113107

114108
- a DB model - :ref:`FsRefreshTracker <refresh_tracker_model>` to keep track of each refresh token family
115109
- a view :py:data:`SECURITY_REFRESH_TOKEN_URL` that issues a new authentication
116110
token given a valid refresh token
117111
- refresh token rotation
118112
- built-in re-use protection
113+
- return refresh token as a cookie (default) OR in the JSON response
119114
- revoke refresh token/tracker on logout
120115

121116
The refresh token (when enabled) is returned along with the authentication token from any
122-
authentication endpoint. Each new refresh token is tracked in the DB with a FsRefreshTracker entry.
117+
authentication endpoint. By default, it is returned as a cookie (http-only).
118+
Each new refresh token is tracked in the DB with a FsRefreshTracker entry.
123119
Each time a new authentication_token is requested (via the .refresh_token endpoint) the generation
124120
number of the refresh tracker entry is incremented, and a new refresh token is generated and returned.
125121
This effectively makes refresh tokens single use.
126122

127123
The ``.logout`` endpoint takes a ``refresh_token`` as an optional item - if supplied - that refresh token
128-
and the associated tracker will be revoked - this should be the best practice for any application.
124+
and the associated tracker will be revoked - this should be the best practice for any application. If the
125+
refresh token is being managed via a cookie, the cookie will be deleted.
129126

130127
.. _freshness_topic:
131128

@@ -154,6 +151,8 @@ Flask-Security itself uses this as part of securing the following endpoints:
154151
- .us_setup ("/us-setup")
155152
- .mf_recovery_codes ("/mf-recovery-codes")
156153
- .change_email ("/change-email")
154+
- .change_username ("/change-username")
155+
- .change_password ("/change")
157156

158157
using the :py:data:`SECURITY_FRESHNESS` and :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` configuration variables.
159158

@@ -184,6 +183,8 @@ The following endpoints accept a ``next`` parameter:
184183
- .wan_signin_response ("/wan-signin")
185184
- .us_signin ("/us-signin")
186185
- .us_verify ("/us-verify")
186+
- .oauthstart ("/oauthstart")
187+
- .oauth_verify_start ("/oauth-verify-start")
187188

188189
Flask-Security always quotes the path portion of a user supplied URL.
189190
This `link <https://www.cve.org/CVERecord?id=CVE-2021-32618>`_ provides background of why simple parsing of URLs isn't enough.
@@ -285,6 +286,11 @@ and the application endpoints (protected with Flask-Security decorators such as
285286
If your application just uses forms that are derived from ``Flask-WTF::Flaskform`` - you are done.
286287
Note that all of Flask-Security's endpoints are form based (regardless of how the request was made).
287288

289+
.. note::
290+
The logout endpoint has never been form-based and has never had CSRF protection. With the addition
291+
of the refresh token feature, logout now accepts form/json input - but still does not support
292+
CSRF protection.
293+
288294
Behind-The-Scenes
289295
++++++++++++++++++
290296
Depending on configuration, there are 3 places CSRF tokens can be checked:
@@ -371,7 +377,7 @@ the csrf-token, and if it can't, it will check if the request has a header that
371377
Be aware that if you enable this it will ONLY work if you send the session cookie on each request.
372378

373379
.. note::
374-
It is IMPORTANT that you initialize/call ``CSRFProtect`` PRIOR to initializing Flask_Security.
380+
It is IMPORTANT that you initialize/call ``CSRFProtect`` PRIOR to initializing Flask-Security.
375381

376382
.. note::
377383
Calling CSRFProtect(app) will setup a @before_request handler to verify CSRF - this occurs BEFORE any Flask-Security decorators

flask_security/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,12 @@
302302
"REFRESH_TOKEN_CLEANUP_EXPIRED": True,
303303
"REFRESH_TOKEN_CLEANUP_REVOKED": False,
304304
"REFRESH_TOKEN_URL": "/refresh-token",
305+
"REFRESH_TOKEN_COOKIE_NAME": "fs_refresh",
306+
"REFRESH_TOKEN_COOKIE": {
307+
"samesite": "Strict",
308+
"httponly": True,
309+
"secure": True,
310+
},
305311
"CONFIRM_SALT": "confirm-salt",
306312
"RESET_SALT": "reset-salt",
307313
"LOGIN_SALT": "login-salt",

flask_security/forms.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,13 @@ class LogoutForm(Form):
669669
refresh_errors: RefreshTokenErrors | None = None
670670
refresh_tracker: RefreshTrackerMixin | None = None
671671

672+
def __init__(self, *args: t.Any, **kwargs: t.Any):
673+
super().__init__(*args, **kwargs)
674+
# If cookie name set - then use that - NOT from the form
675+
if request and cv("REFRESH_TOKEN") and cv("REFRESH_TOKEN_COOKIE_NAME"):
676+
token = request.cookies.get(cv("REFRESH_TOKEN_COOKIE_NAME"), default=None)
677+
self.refresh_token.data = token
678+
672679
def validate(self, **kwargs: t.Any) -> bool:
673680
from .tokens import verify_refresh_token
674681

flask_security/tokens.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,25 @@
1414
1515
TODO
1616
- add ability for app to add/verify additional stuff in refresh_token
17-
- add support for HTTP-only cookie (watch for CSRF issue)
17+
- add endpoint to user can revoke refresh token?
1818
- change TOKEN_MAX_AGE to accept timedelta and change default to 30minutes or so.
19-
- implment rotation overlap period:
19+
- implment rotation overlap period?:
2020
https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-rotation
2121
2222
"""
2323

2424
from __future__ import annotations
2525

26+
from copy import copy
2627
from enum import Enum, auto
2728
import typing as t
29+
from functools import partial
2830

29-
from flask import abort, request, after_this_request, current_app
31+
from flask import abort, request, after_this_request, current_app, Response
3032
from itsdangerous import BadSignature
3133
from wtforms import StringField
3234

35+
from .decorators import unauth_csrf
3336
from .forms import (
3437
Form,
3538
RequiredLocalize,
@@ -40,7 +43,13 @@
4043
)
4144
from .proxies import _security, _datastore
4245
from .signals import refresh_tracker_revoked, refresh_tracker_created
43-
from .utils import config_value as cv, _, view_commit, get_message, base_render_json
46+
from .utils import (
47+
config_value as cv,
48+
_,
49+
view_commit,
50+
get_message,
51+
base_render_json,
52+
)
4453

4554
if t.TYPE_CHECKING: # pragma: no cover
4655
from flask.typing import ResponseValue
@@ -73,7 +82,10 @@ def response_tokens(user: UserMixin, payload: dict[str, t.Any]) -> None:
7382
if _security.refresh_token:
7483
after_this_request(view_commit)
7584
refresh_tracker, refresh_token = new_refresh_tracker(user, name="default")
76-
payload["user"]["refresh_token"] = refresh_token
85+
if cv("REFRESH_TOKEN_COOKIE_NAME"):
86+
after_this_request(partial(set_refresh_token_cookie, token=refresh_token))
87+
else:
88+
payload["user"]["refresh_token"] = refresh_token
7789

7890

7991
def new_refresh_tracker(user: UserMixin, name: str) -> tuple[RefreshTrackerMixin, str]:
@@ -122,6 +134,7 @@ def get_refresh_token(refresh_tracker: RefreshTrackerMixin, user: UserMixin) ->
122134
"ver": str(1),
123135
"uid": getattr(user, _datastore.get_token_uniquifier_name()),
124136
"expires_at": refresh_tracker.expires_at.timestamp(),
137+
"last_used_at": refresh_tracker.last_used_at.timestamp(),
125138
"name": refresh_tracker.name,
126139
"family": refresh_tracker.refresh_family,
127140
"gen": refresh_tracker.gen,
@@ -195,6 +208,13 @@ class RefreshTokenForm(Form):
195208
refresh_tracker: RefreshTrackerMixin | None = None
196209
user: UserMixin | None = None
197210

211+
def __init__(self, *args: t.Any, **kwargs: t.Any):
212+
super().__init__(*args, **kwargs)
213+
# If cookie name set - then use that - NOT anything in the form
214+
if request and cv("REFRESH_TOKEN") and cv("REFRESH_TOKEN_COOKIE_NAME"):
215+
token = request.cookies.get(cv("REFRESH_TOKEN_COOKIE_NAME"), default=None)
216+
self.refresh_token.data = token
217+
198218
def validate(self, **kwargs: t.Any) -> bool:
199219
if not super().validate(**kwargs): # pragma: no cover
200220
return False
@@ -222,6 +242,7 @@ def validate(self, **kwargs: t.Any) -> bool:
222242
return True
223243

224244

245+
@unauth_csrf()
225246
def refresh() -> ResponseValue:
226247
if not request.is_json:
227248
abort(400)
@@ -238,9 +259,12 @@ def refresh() -> ResponseValue:
238259
payload = dict()
239260
payload["user"] = form.user.get_security_payload()
240261
payload["user"]["authentication_token"] = form.user.get_auth_token()
241-
payload["user"]["refresh_token"] = get_refresh_token(
242-
form.refresh_tracker, form.user
243-
)
262+
263+
refresh_token = get_refresh_token(form.refresh_tracker, form.user)
264+
if cv("REFRESH_TOKEN_COOKIE_NAME"):
265+
after_this_request(partial(set_refresh_token_cookie, token=refresh_token))
266+
else:
267+
payload["user"]["refresh_token"] = refresh_token
244268
return _security._render_json(payload, 200, None, form.user)
245269

246270
# Failed validation - if it was a GEN_MISMATCH this could be a hack and we should
@@ -249,3 +273,21 @@ def refresh() -> ResponseValue:
249273
assert form.refresh_tracker
250274
_revoke_refresh_tracker(form.refresh_tracker, form.refresh_errors, form.user)
251275
return base_render_json(form, include_user=False)
276+
277+
278+
def set_refresh_token_cookie(response: Response, token: str) -> Response:
279+
cookie_kwargs = copy(cv("REFRESH_TOKEN_COOKIE"))
280+
response.set_cookie(cv("REFRESH_TOKEN_COOKIE_NAME"), value=token, **cookie_kwargs)
281+
# This is likely overkill since so far we only return this on a POST which is
282+
# unlikely to be cached.
283+
response.vary.add("Cookie")
284+
return response
285+
286+
287+
def clear_refresh_token_cookie(response: Response) -> Response:
288+
cookie_kwargs = copy(cv("REFRESH_TOKEN_COOKIE"))
289+
# Alas delete_cookie only accepts some of the keywords set_cookie does
290+
allowed = ["path", "domain", "secure", "httponly", "samesite", "partitioned"]
291+
args = {k: cookie_kwargs.get(k) for k in allowed if k in cookie_kwargs}
292+
response.delete_cookie(cv("REFRESH_TOKEN_COOKIE_NAME"), **args)
293+
return response

flask_security/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Flask-Security utils module
66
77
:copyright: (c) 2012-2019 by Matt Wright.
8-
:copyright: (c) 2019-2025 by J. Christopher Wagner (jwag).
8+
:copyright: (c) 2019-2026 by J. Christopher Wagner (jwag).
99
:license: MIT, see LICENSE for more details.
1010
"""
1111

@@ -34,6 +34,7 @@
3434
render_template,
3535
session,
3636
url_for,
37+
after_this_request,
3738
)
3839
from flask_login import login_user as _login_user
3940
from flask_login import logout_user as _logout_user
@@ -284,6 +285,10 @@ def logout_user() -> None:
284285
# in 'g'. Be sure to clear both. This affects at least /confirm
285286
g.pop(csrf_field_name, None)
286287
session["fs_cc"] = "clear"
288+
if config_value("REFRESH_TOKEN") and config_value("REFRESH_TOKEN_COOKIE_NAME"):
289+
from .tokens import clear_refresh_token_cookie
290+
291+
after_this_request(partial(clear_refresh_token_cookie))
287292
identity_changed.send(
288293
current_app._get_current_object(), # type: ignore
289294
_async_wrapper=current_app.ensure_sync,

flask_security/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
url_for_security,
132132
view_commit,
133133
allowed_auth_token,
134+
set_request_attr,
134135
)
135136
from .webauthn import (
136137
has_webauthn,
@@ -275,10 +276,19 @@ def verify():
275276

276277

277278
def logout():
278-
"""View function which handles a logout request."""
279+
"""View function which handles a logout request.
280+
logout has never been CSRF protected.
281+
As part of the refresh_token feature, logout now has a form defined
282+
which a client can pass a refresh token that will be revoked as part of logout
283+
(if the refresh token is managed with a cookie, the value will be set into the
284+
form).
285+
The cookie AND refresh tracker/token will be revoked.
286+
"""
279287
tf_clean_session()
280288

281289
if is_user_authenticated(current_user):
290+
# Until we implement logout CSRF - shouldn't logout but NOT revoke refresh token
291+
set_request_attr("csrf_valid", True)
282292
form = t.cast(
283293
LogoutForm, build_form_from_request("logout_form", user=current_user)
284294
)

0 commit comments

Comments
 (0)