From 7303c274357092dde2d6f64fcbe9d4845c80b01f Mon Sep 17 00:00:00 2001 From: yoni Date: Tue, 21 Jul 2026 00:06:40 +0000 Subject: [PATCH] Reject OTP verify responses lacking a usable expiry --- strix/viewer/auth.py | 21 +++++++++++++++------ tests/test_viewer_auth.py | 13 ++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/strix/viewer/auth.py b/strix/viewer/auth.py index c6210880..1c7c5fef 100644 --- a/strix/viewer/auth.py +++ b/strix/viewer/auth.py @@ -58,16 +58,15 @@ def read_auth() -> dict[str, Any] | None: return data -def _expiry(record: dict[str, Any]) -> datetime | None: - """Parse ``verified_at`` (the relay's ``expires_at``) into an aware UTC datetime. +def parse_expiry(raw: object) -> datetime | None: + """Parse a relay ``expires_at`` value into an aware UTC datetime. Accepts both ISO 8601 strings and epoch seconds (as a number or numeric string) so a valid relay expiry is not misread as missing. Returns None only - when it is genuinely absent or unparseable; the local gate then fails closed - (see ``is_verified``), matching the relay, which rejects a token with no valid - expiry on report send. + when it is genuinely absent or unparseable; both the local gate (see + ``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such + values, matching the relay, which rejects a token with no valid expiry. """ - raw = record.get("verified_at") if isinstance(raw, bool): return None if isinstance(raw, int | float): @@ -85,6 +84,11 @@ def _expiry(record: dict[str, Any]) -> datetime | None: return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) +def _expiry(record: dict[str, Any]) -> datetime | None: + """The stored ``verified_at`` parsed to a datetime, or None if unusable.""" + return parse_expiry(record.get("verified_at")) + + def _from_epoch(seconds: float) -> datetime | None: """Epoch seconds → aware UTC datetime, or None if out of range.""" try: @@ -192,6 +196,11 @@ def otp_verify(email: str, code: str) -> dict[str, Any]: timeout=_OTP_TIMEOUT, ) if status == 200 and isinstance(data.get("token"), str): + # A token with no usable expiry cannot unlock history locally (the gate + # fails closed), so treat such a response as a failed verification rather + # than reporting success and then leaving the user stuck unverified. + if parse_expiry(data.get("expires_at")) is None: + raise RelayError("unavailable") return data if status == 403: raise RelayError("invalid_code") diff --git a/tests/test_viewer_auth.py b/tests/test_viewer_auth.py index fe10b0fb..049f80a2 100644 --- a/tests/test_viewer_auth.py +++ b/tests/test_viewer_auth.py @@ -122,7 +122,8 @@ def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None: def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None: - _stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": "later"}) + expires = _iso(timedelta(hours=1)) + _stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires}) result = auth.otp_verify("a@b.com", "123456") assert result["token"] == "t" @@ -132,6 +133,16 @@ def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None assert exc.value.code == "invalid_code" +def test_otp_verify_rejects_token_without_usable_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + # A 200 with a token but no valid expiry must not be reported as success, + # otherwise the caller would store a record that immediately reads unverified. + for expires in (None, "", "later"): + _stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires}) + with pytest.raises(auth.RelayError) as exc: + auth.otp_verify("a@b.com", "123456") + assert exc.value.code == "unavailable" + + def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {}