@@ -146,6 +146,43 @@ def _read_header_token(
146146 return None , consulted
147147
148148
149+ def _add_vary (headers : MutableHeaders , names : list [str ]) -> None :
150+ """Add header names to ``Vary``, keeping the values already there.
151+
152+ Starlette's ``add_vary_header`` appends unconditionally, so names the
153+ response already varies on (compared case-insensitively) are skipped, as
154+ is everything when it already varies on ``*``.
155+
156+ Args:
157+ headers: Mutable response headers to write to
158+ names: Request header names the response depends on
159+ """
160+ present = {
161+ value .strip ().lower ()
162+ for line in headers .getlist ("vary" )
163+ for value in line .split ("," )
164+ }
165+ if "*" in present :
166+ return
167+ for name in names :
168+ if name .lower () not in present :
169+ headers .add_vary_header (name )
170+ present .add (name .lower ())
171+
172+
173+ def _forbid_storing (headers : MutableHeaders ) -> None :
174+ """Keep a response that carries a session token out of every cache.
175+
176+ The token is a credential: a shared cache that stored the response would
177+ hand it to the next visitor. This replaces any ``Cache-Control`` the route
178+ set, ``public`` and ``max-age`` included.
179+
180+ Args:
181+ headers: Mutable response headers to write to
182+ """
183+ headers ["Cache-Control" ] = "private, no-store"
184+
185+
149186def _stash_session_manager (app : Any , manager : SessionManager ) -> None :
150187 """Register the session manager on ``app.state`` for dependency injection.
151188
@@ -254,12 +291,15 @@ async def dispatch(
254291 if session is not None and session .session_id != loaded_session_id :
255292 # The handler regenerated the session ID; a renewed token would
256293 # name the deleted record, so send a token for the new ID.
257- response .headers [self .config .header_name ] = (
258- self .session_manager .issue_token (session )
259- )
260- elif renewed_token is not None :
294+ response_token : str | None = self .session_manager .issue_token (session )
295+ else :
261296 # Propagate renewed token to client so its JWT exp stays in sync
262- response .headers [self .config .header_name ] = renewed_token
297+ response_token = renewed_token
298+
299+ if response_token is not None :
300+ response .headers [self .config .header_name ] = response_token
301+ _add_vary (response .headers , _read_header_token (request , self .config )[1 ])
302+ _forbid_storing (response .headers )
263303
264304 return response
265305
@@ -408,11 +448,7 @@ async def send_wrapper(message: Message) -> None:
408448 backend_session , loaded_session_id , loaded_token , renewed_token
409449 )
410450
411- if session .accessed :
412- for name in vary_on :
413- headers .add_vary_header (name )
414-
415- await self ._persist (
451+ sent_token = await self ._persist (
416452 session ,
417453 headers ,
418454 connection ,
@@ -422,6 +458,11 @@ async def send_wrapper(message: Message) -> None:
422458 from_header = from_header ,
423459 )
424460
461+ if session .accessed or sent_token :
462+ _add_vary (headers , vary_on )
463+ if sent_token :
464+ _forbid_storing (headers )
465+
425466 await send (message )
426467
427468 await self .app (scope , receive , send_wrapper )
@@ -436,8 +477,13 @@ async def _persist( # noqa: PLR0913, PLR0917
436477 fresh_token : str | None ,
437478 * ,
438479 from_header : bool ,
439- ) -> None :
440- """Save, delete or renew the session according to what the request did to it."""
480+ ) -> bool :
481+ """Save, delete or renew the session according to what the request did to it.
482+
483+ Returns:
484+ True if a token or a clearing cookie was written to the response
485+ """
486+ sent_token = False
441487 target = backend_session
442488 if session .cleared and target is not None :
443489 # clear() logs out, whatever the data held. Anything
@@ -448,6 +494,7 @@ async def _persist( # noqa: PLR0913, PLR0917
448494 # Cookie transport: expire the cookie. A header-based
449495 # client simply drops its now-dangling token.
450496 headers .append ("Set-Cookie" , self ._build_clear_cookie_header ())
497+ sent_token = True
451498
452499 if session .modified and (
453500 session or (target is not None and target .user is not None )
@@ -467,16 +514,20 @@ async def _persist( # noqa: PLR0913, PLR0917
467514 token_to_emit = new_token if from_header else cookie_token
468515 if token_to_emit is not None :
469516 self ._emit_token (headers , token_to_emit , from_header = from_header )
517+ sent_token = True
470518 elif session .modified and target is not None :
471519 # An anonymous session left empty holds nothing to keep.
472520 await self .session_manager .delete_session (target .session_id )
473521 if not from_header :
474522 headers .append ("Set-Cookie" , self ._build_clear_cookie_header ())
523+ sent_token = True
475524 elif fresh_token is not None :
476525 # Sliding expiration renewed the token, or the ID was
477526 # regenerated, even though the dict itself was untouched;
478527 # propagate it via the same transport.
479528 self ._emit_token (headers , fresh_token , from_header = from_header )
529+ sent_token = True
530+ return sent_token
480531
481532 def _token_sources (
482533 self , connection : HTTPConnection
0 commit comments