Skip to content

Commit f28bdbd

Browse files
authored
Refresh Token feature. (#1207)
* Refresh Token feature. - new DB model - new view for getting new auth_token using refresh token - implement refresh token rotation - implement re-use detection * For testing restrict cbor2 to pre-Rust implementation. peewee in particular used memoryviews which aren't supported. * Bump pypy to 3.11 to try to get tests to pass.
1 parent edcb5a5 commit f28bdbd

31 files changed

Lines changed: 1472 additions & 152 deletions

.github/workflows/tests.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ jobs:
3232
- {python: '3.13', tox: 'py313-release' }
3333
- {python: '3.13', tox: 'py313-low' }
3434
- {python: '3.14', tox: 'py314-release' }
35-
- {python: 'pypy-3.10', tox: 'pypy310-release'}
36-
- {python: 'pypy-3.10', tox: 'pypy310-low'}
35+
- {python: 'pypy-3.11', tox: 'pypy311-release'}
3736

3837
steps:
3938
- uses: actions/checkout@v6

CHANGES.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ Flask-Security Changelog
33

44
Here you can see the full list of changes between each Flask-Security release.
55

6+
Version 5.9.0
7+
-------------
8+
9+
Released TBD
10+
11+
Features & Improvements
12+
+++++++++++++++++++++++
13+
- (:issue:`1206`) Add support for refresh tokens. See :ref:`token_topic`
14+
15+
Notes
16+
+++++
17+
- The refresh token features uses a new DB model - FsRefreshTracker which must
18+
be added by the application. This model has been added to the `fsqla` and `sqla`
19+
all-inclusive models their respective versions have been bumped.
20+
621
Version 5.8.0
722
-------------
823

README.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ Goals
6363
* Follow the `Pallets <https://github.com/pallets>`_ lead on supported versions, documentation
6464
standards and any other guidelines for extensions that they come up with.
6565
* Continue to add newer authentication/authorization standards:
66+
* Support for Refresh Tokens (5.9)
67+
* Support username recovery and changing username (5.6)
68+
* Support for changing email (5.5)
6669
* 'Social Login (OAuth)' integrated (using authlib) (5.1)
6770
* WebAuthn/Passkey support (5.0)
6871
* Two-Factor recovery codes (5.0)

docs/api.rst

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ User Object Helpers
5252
.. autoclass:: flask_security.WebAuthnMixin
5353
:members:
5454

55+
.. autoclass:: flask_security.RefreshTrackerMixin
56+
:members:
57+
5558

5659
Datastores
5760
----------
@@ -60,7 +63,8 @@ Datastores
6063
:exclude-members: create_webauthn, find_user_from_webauthn,
6164
mf_set_recovery_codes, mf_delete_recovery_code,
6265
set_webauthn_user_handle,
63-
us_get_totp_secrets, us_put_totp_secrets
66+
us_get_totp_secrets, us_put_totp_secrets,
67+
create_refresh_tracker,
6468

6569
.. autoclass:: flask_security.SQLAlchemyUserDatastore
6670
:show-inheritance:
@@ -111,6 +115,11 @@ Datastores
111115
The WebAuthn model. This must be provided by the application.
112116
See :ref:`Models <models_topic>`.
113117

118+
.. class:: FsRefreshTracker
119+
120+
The RefreshTracker model. This must be provided by the application.
121+
See :ref:`Models <models_topic>`.
122+
114123
Packaged Models
115124
---------------
116125
.. autoclass:: flask_security.models.fsqla.FsModels
@@ -239,6 +248,7 @@ Forms
239248
.. autoclass:: flask_security.MfRecoveryCodesForm
240249
.. autoclass:: flask_security.MfRecoveryForm
241250
.. autoclass:: flask_security.PasswordlessLoginForm
251+
.. autoclass:: flask_security.RefreshTokenForm
242252
.. autoclass:: flask_security.RegisterForm
243253
.. autoclass:: flask_security.RegisterFormV2
244254
.. autoclass:: flask_security.ResetPasswordForm
@@ -372,6 +382,24 @@ sends the following signals.
372382

373383
.. versionadded:: 5.5.0
374384

385+
.. data:: refresh_tracker_created
386+
387+
Sent when a user has authenticated, has requested an auth_token, and
388+
the :py:data:`SECURITY_REFRESH_TOKEN` feature is enabled.
389+
In addition to the app (which is the sender), it is passed the `user` and
390+
the `refresh_tracker` objects.
391+
392+
.. versionadded:: 5.9.0
393+
394+
.. data:: refresh_tracker_revoked
395+
396+
Sent when a refresh tracker is revoked as part of the ``/refresh`` view
397+
when the provided refresh token doesn't match the current generation #.
398+
In addition to the app (which is the sender), it is passed the `user` and
399+
the `refresh_tracker` objects, and the `refresh_error` tuple.
400+
401+
.. versionadded:: 5.9.0
402+
375403
.. data:: tf_code_confirmed
376404

377405
Sent when a user performs two-factor authentication login on the site. In

docs/configuration.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,10 @@ Core - rarely need changing
759759
.. py:data:: SECURITY_WAN_SALT
760760
761761
Default: ``"wan-salt"``
762+
763+
.. py:data:: SECURITY_REFRESH_TOKEN_SALT
764+
765+
Default: ``"refresh-token-salt"``
762766
.. py:data:: SECURITY_TWO_FACTOR_SETUP_SALT
763767
764768
Default: ``"tf-setup-salt"``
@@ -2024,6 +2028,46 @@ Social Login (OAuth)
20242028

20252029
.. versionadded:: 5.8.0
20262030

2031+
Refresh Tokens
2032+
---------------
2033+
2034+
.. versionadded:: 5.9.0
2035+
2036+
.. py:data:: SECURITY_REFRESH_TOKEN
2037+
2038+
Specifies if Flask-Security should enable refresh token support.
2039+
2040+
Default: ``False``.
2041+
.. py:data:: SECURITY_REFRESH_TOKEN_URL
2042+
2043+
Specifies the URL used to get new auth_tokens using an existing
2044+
refresh token
2045+
2046+
Default: ``"/refresh-token"``.
2047+
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_AGE
2048+
2049+
Specifies a timedelta used to compute the tokens expiration date.
2050+
2051+
Default: ``timedelta(days=90)``
2052+
.. py:data:: SECURITY_REFRESH_TOKEN_MAX_IDLE
2053+
2054+
Specifies a timedelta used to compute the date the refresh token will
2055+
considered expired due to none use.
2056+
2057+
Default: ``timedelta(days=7)``
2058+
.. py:data:: SECURITY_REFRESH_TOKEN_CLEANUP_EXPIRED
2059+
2060+
If True then expired refresh tokens database trackers will be deleted
2061+
whenever the user creates a new one (re-authenticates)
2062+
2063+
Default: ``True``
2064+
.. py:data:: SECURITY_REFRESH_TOKEN_CLEANUP_REVOKED
2065+
2066+
If True then expired refresh tokens database trackers will be deleted
2067+
whenever the user creates a new one (re-authenticates)
2068+
2069+
Default: ``False``
2070+
20272071
Feature Flags
20282072
-------------
20292073
All feature flags. By default all are ``False``/not enabled.
@@ -2042,6 +2086,7 @@ All feature flags. By default all are ``False``/not enabled.
20422086
* :py:data:`SECURITY_WEBAUTHN`
20432087
* :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES`
20442088
* :py:data:`SECURITY_OAUTH_ENABLE`
2089+
* :py:data:`SECURITY_REFRESH_TOKEN`
20452090

20462091
URLs and Views
20472092
--------------
@@ -2083,6 +2128,7 @@ A list of all URLs and Views:
20832128
* :py:data:`SECURITY_RESET_ERROR_VIEW`
20842129
* :py:data:`SECURITY_LOGIN_ERROR_VIEW`
20852130
* :py:data:`SECURITY_USERNAME_RECOVERY_URL`
2131+
* :py:data:`SECURITY_REFRESH_TOKEN_URL` ``"/refresh-token"``
20862132
* :py:data:`SECURITY_US_SIGNIN_URL`
20872133
* :py:data:`SECURITY_US_SETUP_URL`
20882134
* :py:data:`SECURITY_US_SIGNIN_SEND_CODE_URL`
@@ -2186,6 +2232,7 @@ The default messages and error levels can be found in ``core.py``.
21862232
* ``SECURITY_MSG_REAUTHENTICATION_REQUIRED``
21872233
* ``SECURITY_MSG_REAUTHENTICATION_SUCCESSFUL``
21882234
* ``SECURITY_MSG_REFRESH``
2235+
* ``SECURITY_REFRESH_TOKEN_INVALID``
21892236
* ``SECURITY_MSG_RETYPE_PASSWORD_MISMATCH``
21902237
* ``SECURITY_MSG_TWO_FACTOR_INVALID_TOKEN``
21912238
* ``SECURITY_MSG_TWO_FACTOR_LOGIN_SUCCESSFUL``

docs/customizing.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ The following is a list of all the available form overrides:
177177
* ``mf_recovery_codes_form``: Setup recovery codes form
178178
* ``mf_recovery_form``: Use recovery code form
179179
* ``passwordless_login_form``: Passwordless login form
180+
* ``refresh_token_form``: Refresh Token form (:py:class:`flask_security.RefreshTokenForm`)
180181
* ``two_factor_verify_code_form``: Two-factor verify code form
181182
* ``two_factor_select_form``: Two-factor select form
182183
* ``two_factor_setup_form``: Two-factor setup form

docs/features.rst

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,30 +87,8 @@ through an HTTP header or query string parameter. By default the HTTP header
8787
name is `Authentication-Token` and the default query string parameter name is
8888
`auth_token`.
8989

90-
Authentication tokens are generated using a uniquifier field in the
91-
user's UserModel. By default that field is ``fs_uniquifier``. This means that
92-
if that field is changed (via :meth:`.UserDatastore.set_uniquifier`)
93-
then any existing authentication tokens will no longer be valid. This value is changed
94-
whenever a user changes their password. If this is not the desired behavior then you can add an additional
95-
attribute to the UserModel: ``fs_token_uniquifier`` and that will be used instead, thus
96-
isolating password changes from authentication tokens. That attribute can be changed via
97-
:meth:`.UserDatastore.set_token_uniquifier`. This attribute should have ``unique=True``.
98-
Unlike ``fs_uniquifier``, it can be set to ``nullable`` - it will automatically be generated
99-
at first use if null.
100-
101-
Authentication tokens have 2 options for specifying expiry time :data:`SECURITY_TOKEN_MAX_AGE`
102-
is applied to ALL authentication tokens. Each authentication token can itself have an embedded
103-
expiry value (settable via the :data:`SECURITY_TOKEN_EXPIRE_TIMESTAMP` callable).
104-
105-
Authentication tokens also convey freshness by recording the time the token was generated.
106-
This is used for endpoints protected with :func:`.auth_required` with a ``within``
107-
value set.
108-
109-
.. note::
110-
While every Flask-Security endpoint will accept an authentication token header,
111-
there are some endpoints that require session information (e.g. a session cookie).
112-
This includes entering in a second factor and handling of :ref:`CSRF<csrf_topic>`.
113-
As of release 5.5.0, authentication tokens by default carry freshness information.
90+
If :ref:`configured<configuration:Refresh Tokens>`, a robust refresh token feature is available.
91+
See :ref:`token_topic`.
11492

11593
User Registration
11694
-----------------

docs/models.rst

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ depending on the functionality your app requires. Aside from this, you're free
1313
to add any additional fields to your model(s) if you want.
1414

1515
.. note::
16-
The User, Role, and WebAuthn models MUST subclass their respective mixin (along
16+
The User, Role, WebAuthn, and RefreshTracker models MUST subclass their respective mixin (along
1717
with any other mixin the datastore requires). The pre-packaged models described
1818
below do this for you.
1919

2020
.. code-block:: python
2121
22-
from flask_security import UserMixin, RoleMixin, WebAuthMixin
22+
from flask_security import UserMixin, RoleMixin, WebAuthMixin, RefreshTrackerMixin
2323
2424
class User(<db specific base>, UserMixin):
2525
... define columns here
@@ -45,15 +45,15 @@ using 'raw' sqlalchemy or Flask-SQLAlchemy-Lite.
4545
4646
.. note::
4747
Using these models, you can override the tables names (and provide them to .set_db_info()
48-
however your model class names MUST be `User`, `Role`, and `WebAuthn`.
48+
however your model class names MUST be `User`, `Role`, `WebAuthn`, and `RefreshTracker`.
4949
5050
Flask-SQLAlchemy
5151
^^^^^^^^^^^^^^^^
5252
Your application code should import just the required version e.g.:
5353
5454
.. code-block:: python
5555
56-
from flask_security.models import fsqla_v3 as fsqla
56+
from flask_security.models import fsqla_v2 as fsqla
5757
from flask_sqlalchemy import SQLAlchemy
5858
5959
db = SQLAlchemy(app)
@@ -194,7 +194,7 @@ will require the following additional field:
194194
195195
Separate Identity Domains
196196
^^^^^^^^^^^^^^^^^^^^^^^^^
197-
If you want authentication tokens to not be invalidated when the user changes their
197+
If you want authentication and refresh tokens to not be invalidated when the user changes their
198198
password add the following to your `User` model:
199199
200200
* ``fs_token_uniquifier`` (string, 64 bytes, unique, non-nullable)
@@ -308,6 +308,87 @@ the User record (since we need to look up the ``User`` based on a WebAuthn ``cre
308308
calls ``delete_instance(recursive=True)`` which correctly deals with ensuring
309309
that WebAuthn records get deleted if a User is deleted.
310310
311+
.. _refresh_tracker_model:
312+
313+
Refresh Tokens
314+
^^^^^^^^^^^^^^
315+
Flask Security supports refresh tokens by enabling
316+
:py:data:`SECURITY_REFRESH_TOKEN`. Internal to Flask-Security, refresh tokens
317+
are tracked using a ``refresh_tracker`` table. This requires an additional table as well as
318+
references from the User model. Users can have many refresh trackers/tokens.
319+
320+
.. important::
321+
It is important that you maintain data consistency when deleting refresh_tracker
322+
records or users.
323+
324+
The `FsRefreshTracker` model requires the following fields:
325+
326+
* ``id`` (primary key)
327+
* ``refresh_family`` (string, 64 bytes, indexed, non-nullable, unique)
328+
* ``gen`` (integer, non-nullable)
329+
* ``expires_at`` (datetime, non-nullable)
330+
* ``revoked_at`` (datetime)
331+
* ``last_used_at`` (datetime, non-nullable)
332+
* ``name`` (string, 64 bytes, non-nullable)
333+
334+
The User record needs to have a list of refresh trackers.
335+
336+
**For SQLAlchemy**:
337+
338+
- Add the following to the FsRefreshTracker model (assuming your primary key is named ``id``):
339+
340+
.. code-block:: python
341+
342+
@declared_attr
343+
def user_id(cls) -> Mapped[int]:
344+
return mapped_column(
345+
ForeignKey("user.id", ondelete="CASCADE")
346+
)
347+
348+
- Add the following to the User model:
349+
350+
.. code-block:: python
351+
352+
@declared_attr
353+
def refresh_trackers(cls):
354+
return relationship(
355+
"FsRefreshTracker", cascade="all, delete"
356+
)
357+
358+
**For mongoengine**:
359+
360+
- Add the following to the FsRefreshTracker model:
361+
362+
.. code-block:: python
363+
364+
user = ReferenceField("User")
365+
366+
- Add the following to the User model:
367+
368+
.. code-block:: python
369+
370+
refresh_trackers = ListField(ReferenceField(FsRefreshTracker, reverse_delete_rule=PULL), default=[])
371+
372+
- To make sure all FsRefreshTracker objects are deleted if the User is deleted:
373+
374+
.. code-block:: python
375+
376+
User.register_delete_rule(FsRefreshTracker, "user", CASCADE)
377+
378+
**For peewee**:
379+
380+
Add the following to the FsRefreshTracker model:
381+
382+
.. code-block:: python
383+
384+
user = ForeignKeyField(User, backref="refresh_trackers")
385+
386+
This will add a column called ``user_id`` that references the User model's
387+
``id`` primary key field. It will also create a virtual column ``refresh_trackers``
388+
as part of the User model. Note that the default Peewee datastore implementation
389+
calls ``delete_instance(recursive=True)`` which correctly deals with ensuring
390+
that FsRefreshTracker records get deleted if a User is deleted.
391+
311392
312393
Recovery Codes
313394
^^^^^^^^^^^^^^^

0 commit comments

Comments
 (0)