"""SignedTokenCodec: tamper-evidence and expiry.""" from __future__ import annotations import base64 from collections.abc import Callable from datetime import UTC, datetime, timedelta import pytest from ragcore.application.auth import SignedTokenCodec, TokenExpiredError, TokenTamperedError SECRET = b"0" * 32 def _codec(clock: Callable[[], datetime] | None = None) -> SignedTokenCodec: kwargs = {} if clock is None else {"clock": clock} return SignedTokenCodec(secret=SECRET, **kwargs) def test_round_trips_a_payload() -> None: codec = _codec() token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5)) body = codec.decode(token) assert body["sub"] == "alice" def test_tampered_payload_is_rejected() -> None: codec = _codec() token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5)) body_b64, signature_b64 = token.split(".", 1) tampered = f"{body_b64}x.{signature_b64}" with pytest.raises(TokenTamperedError): codec.decode(tampered) def test_tampered_signature_is_rejected() -> None: codec = _codec() token = codec.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5)) body_b64, signature_b64 = token.split(".", 1) # Flip a bit in the *decoded* signature bytes and re-encode, rather than # editing the base64 text directly: replacing only the last character of # a base64 string can, depending on the byte length, land on padding # bits that don't change the decoded value at all -- flipping a real # byte always changes the signature. padding = "=" * (-len(signature_b64) % 4) raw_signature = bytearray(base64.urlsafe_b64decode(signature_b64 + padding)) raw_signature[0] ^= 0xFF tampered_signature_b64 = base64.urlsafe_b64encode(bytes(raw_signature)).rstrip(b"=").decode() tampered = f"{body_b64}.{tampered_signature_b64}" with pytest.raises(TokenTamperedError): codec.decode(tampered) def test_malformed_token_shapes_are_rejected() -> None: codec = _codec() for malformed in ("", "no-dot-at-all", "a.b.c", "."): with pytest.raises(TokenTamperedError): codec.decode(malformed) def test_different_secret_cannot_decode_another_codecs_token() -> None: codec_a = SignedTokenCodec(secret=b"a" * 32) codec_b = SignedTokenCodec(secret=b"b" * 32) token = codec_a.encode({"sub": "alice"}, expires_at=datetime.now(UTC) + timedelta(minutes=5)) with pytest.raises(TokenTamperedError): codec_b.decode(token) def test_expired_token_is_rejected_distinctly_from_tampering() -> None: clock_time = {"now": datetime(2026, 1, 1, tzinfo=UTC)} codec = _codec(clock=lambda: clock_time["now"]) token = codec.encode({"sub": "alice"}, expires_at=clock_time["now"] + timedelta(minutes=5)) clock_time["now"] = clock_time["now"] + timedelta(minutes=6) with pytest.raises(TokenExpiredError): codec.decode(token) def test_secret_must_be_at_least_32_bytes() -> None: with pytest.raises(ValueError, match="32 bytes"): SignedTokenCodec(secret=b"too-short") def test_expires_at_must_be_timezone_aware() -> None: codec = _codec() naive = datetime.fromisoformat("2026-01-01T00:00:00") with pytest.raises(ValueError, match="timezone-aware"): codec.encode({"sub": "alice"}, expires_at=naive)