Skip to content

Commit 0d60c10

Browse files
Clear refresh token expiry when the token is rotated
A refresh response that issues a new refresh_token without refresh_token_expires_in no longer inherits the previous token's lifetime. That leftover expiry could make a still-valid session look expired and force another hx auth login. Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent fa10ca2 commit 0d60c10

2 files changed

Lines changed: 111 additions & 4 deletions

File tree

‎hyperbrowser/control_auth.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,13 @@ def _build_refreshed_oauth_session(
644644
details=payload,
645645
)
646646

647+
previous_refresh_token = _normalize_text(previous.get("refresh_token"))
648+
refresh_token = (
649+
_normalize_text(payload.get("refresh_token")) or previous_refresh_token
650+
)
651+
refresh_token_expiry = _derive_expiry(payload.get("refresh_token_expires_in"))
652+
if refresh_token_expiry is None and refresh_token == previous_refresh_token:
653+
refresh_token_expiry = _normalize_text(previous.get("refresh_token_expiry"))
647654
return {
648655
"version": previous.get("version", 1),
649656
"base_url": _normalize_base_url(previous.get("base_url")),
@@ -652,13 +659,11 @@ def _build_refreshed_oauth_session(
652659
or _normalize_text(previous.get("token_type"))
653660
or "Bearer",
654661
"access_token": access_token,
655-
"refresh_token": _normalize_text(payload.get("refresh_token"))
656-
or _normalize_text(previous.get("refresh_token")),
662+
"refresh_token": refresh_token,
657663
"expiry": _derive_expiry(payload.get("expires_in")) or "",
658664
"scope": _normalize_text(payload.get("scope"))
659665
or _normalize_text(previous.get("scope")),
660-
"refresh_token_expiry": _derive_expiry(payload.get("refresh_token_expires_in"))
661-
or _normalize_text(previous.get("refresh_token_expiry")),
666+
"refresh_token_expiry": refresh_token_expiry or "",
662667
}
663668

664669

‎tests/test_control_auth.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
482584
def 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

Comments
 (0)