mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Reject OTP verify responses lacking a usable expiry
This commit is contained in:
+15
-6
@@ -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")
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user