@@ -479,6 +479,108 @@ def handler(request: httpx.Request) -> httpx.Response:
479479 assert headers_again == {"authorization" : "Bearer no-expiry-access" }
480480
481481
482+ def test_rotated_refresh_token_without_expires_in_clears_old_expiry (
483+ auth_home , monkeypatch
484+ ):
485+ old_refresh_expiry = _expiry (minutes = 1 )
486+ write_session (
487+ auth_home ,
488+ access_token = "expired-access" ,
489+ expiry = _expiry (minutes = - 5 ),
490+ refresh_token = "old-refresh" ,
491+ refresh_token_expiry = old_refresh_expiry ,
492+ )
493+
494+ def handler (request : httpx .Request ) -> httpx .Response :
495+ return httpx .Response (
496+ 200 ,
497+ json = {
498+ "access_token" : "rotated-access" ,
499+ "refresh_token" : "rotated-refresh" ,
500+ "expires_in" : 3600 ,
501+ },
502+ )
503+
504+ _patch_httpx_client (monkeypatch , handler )
505+ _ , auth = resolve_control_plane_config (ClientConfig ())
506+ headers , token = auth .authorize_headers ()
507+ assert headers == {"authorization" : "Bearer rotated-access" }
508+ assert token == "rotated-access"
509+
510+ saved = json .loads (
511+ (Path (auth_home ) / ".hx_config" / "auth" / "default.json" ).read_text ()
512+ )
513+ assert saved ["refresh_token" ] == "rotated-refresh"
514+ assert saved ["refresh_token_expiry" ] == ""
515+ assert saved ["refresh_token_expiry" ] != old_refresh_expiry
516+
517+
518+ def test_reused_refresh_token_without_expires_in_keeps_old_expiry (
519+ auth_home , monkeypatch
520+ ):
521+ old_refresh_expiry = _expiry (hours = 2 )
522+ write_session (
523+ auth_home ,
524+ access_token = "expired-access" ,
525+ expiry = _expiry (minutes = - 5 ),
526+ refresh_token = "same-refresh" ,
527+ refresh_token_expiry = old_refresh_expiry ,
528+ )
529+
530+ def handler (request : httpx .Request ) -> httpx .Response :
531+ return httpx .Response (
532+ 200 ,
533+ json = {
534+ "access_token" : "refreshed-access" ,
535+ "expires_in" : 3600 ,
536+ },
537+ )
538+
539+ _patch_httpx_client (monkeypatch , handler )
540+ _ , auth = resolve_control_plane_config (ClientConfig ())
541+ auth .authorize_headers ()
542+
543+ saved = json .loads (
544+ (Path (auth_home ) / ".hx_config" / "auth" / "default.json" ).read_text ()
545+ )
546+ assert saved ["refresh_token" ] == "same-refresh"
547+ assert saved ["refresh_token_expiry" ] == old_refresh_expiry
548+
549+
550+ def test_rotated_refresh_token_uses_new_expires_in (auth_home , monkeypatch ):
551+ old_refresh_expiry = _expiry (minutes = 1 )
552+ write_session (
553+ auth_home ,
554+ access_token = "expired-access" ,
555+ expiry = _expiry (minutes = - 5 ),
556+ refresh_token = "old-refresh" ,
557+ refresh_token_expiry = old_refresh_expiry ,
558+ )
559+
560+ def handler (request : httpx .Request ) -> httpx .Response :
561+ return httpx .Response (
562+ 200 ,
563+ json = {
564+ "access_token" : "rotated-access" ,
565+ "refresh_token" : "rotated-refresh" ,
566+ "expires_in" : 3600 ,
567+ "refresh_token_expires_in" : 7200 ,
568+ },
569+ )
570+
571+ _patch_httpx_client (monkeypatch , handler )
572+ _ , auth = resolve_control_plane_config (ClientConfig ())
573+ auth .authorize_headers ()
574+
575+ saved = json .loads (
576+ (Path (auth_home ) / ".hx_config" / "auth" / "default.json" ).read_text ()
577+ )
578+ assert saved ["refresh_token" ] == "rotated-refresh"
579+ assert saved ["refresh_token_expiry" ] != old_refresh_expiry
580+ assert saved ["refresh_token_expiry" ] != ""
581+ assert _parse_timestamp (saved ["refresh_token_expiry" ]) is not None
582+
583+
482584def test_parse_timestamp_accepts_variable_fractional_seconds ():
483585 assert _parse_timestamp ("2026-08-15T12:00:00Z" ) is not None
484586 assert _parse_timestamp ("2026-08-15T12:00:00.1Z" ) is not None
0 commit comments