Reject OTP verify responses lacking a usable expiry

This commit is contained in:
yoni
2026-07-21 00:06:40 +00:00
parent ab840c31ae
commit 7303c27435
2 changed files with 27 additions and 7 deletions
+15 -6
View File
@@ -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")
+12 -1
View File
@@ -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] = {}