1414
1515TODO
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
2424from __future__ import annotations
2525
26+ from copy import copy
2627from enum import Enum , auto
2728import 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
3032from itsdangerous import BadSignature
3133from wtforms import StringField
3234
35+ from .decorators import unauth_csrf
3336from .forms import (
3437 Form ,
3538 RequiredLocalize ,
4043)
4144from .proxies import _security , _datastore
4245from .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
4554if 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
7991def 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 ()
225246def 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
0 commit comments