Files
13ft/app/test_otm.py
T
tonymandClaude Fable 5.1 71abf89784
Build / Build and release (push) Failing after 19s
feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
app/otm.py is the Python port of pickup's ticket verifier (HS256, mandatory
exp, audience check, alg only read to reject, never raises) plus a signed
session cookie and an in-memory single-use jti guard. portable.py gates every
route but PUBLIC_PATHS behind a session and fails CLOSED (503) with no secret
— it is a fetch proxy. /api/health reports the version, /changelog renders
CHANGELOG.md, the footer carries version/sign-out/Ideas, tides beacon from
TIDES_HOST/TIDES_SITE_ID. Dockerfile: OTM labels + HEALTHCHECK. 17 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LCQmFvKshyAJsE8C3B5ET
2026-10-18 22:22:27 -05:00

96 lines
3.8 KiB
Python

"""python3 -m unittest app/test_otm.py (or `cd app && python -m unittest`)."""
import unittest
from otm import (
JtiGuard,
decode_session,
encode_session,
mint_sso_ticket,
otm_sign_in_url,
safe_next,
verify_sso_ticket,
)
SECRET = "test-secret"
CLAIMS = {"aud": "app:13ft", "sub": "u1", "email": "tony@example.com", "role": "admin"}
class Tickets(unittest.TestCase):
def test_round_trip(self):
t = mint_sso_ticket(SECRET, CLAIMS, now_ms=1_000_000)
p = verify_sso_ticket(SECRET, t, expected_aud="app:13ft", now_ms=1_030_000)
self.assertIsNotNone(p)
self.assertEqual(p["email"], "tony@example.com")
self.assertTrue(p["jti"])
def test_expired(self):
t = mint_sso_ticket(SECRET, CLAIMS, ttl_sec=60, now_ms=1_000_000)
self.assertIsNone(verify_sso_ticket(SECRET, t, expected_aud="app:13ft", now_ms=1_060_000))
def test_wrong_secret_and_wrong_audience(self):
t = mint_sso_ticket(SECRET, CLAIMS)
self.assertIsNone(verify_sso_ticket("other", t, expected_aud="app:13ft"))
self.assertIsNone(verify_sso_ticket(SECRET, t, expected_aud="app:qr"))
def test_tampered_payload_and_junk(self):
t = mint_sso_ticket(SECRET, CLAIMS)
h, p, s = t.split(".")
self.assertIsNone(verify_sso_ticket(SECRET, f"{h}.{p}x.{s}", expected_aud="app:13ft"))
for junk in ("", "a.b", "a.b.c", None, 42, "....."):
self.assertIsNone(verify_sso_ticket(SECRET, junk, expected_aud="app:13ft"))
def test_missing_exp_or_claims_rejected(self):
import base64, json
def b64(o):
return base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip("=")
from otm import _sign
h = b64({"alg": "HS256"})
for payload in ({**CLAIMS, "jti": "x"}, {**CLAIMS, "jti": "x", "exp": 9e12, "email": ""}):
p = b64(payload)
tok = f"{h}.{p}.{_sign(SECRET, f'{h}.{p}')}"
self.assertIsNone(verify_sso_ticket(SECRET, tok, expected_aud="app:13ft"))
def test_alg_none_rejected(self):
import base64, json
h = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).decode().rstrip("=")
p = base64.urlsafe_b64encode(json.dumps({**CLAIMS, "jti": "x", "exp": 9e12}).encode()).decode().rstrip("=")
self.assertIsNone(verify_sso_ticket(SECRET, f"{h}.{p}.", expected_aud="app:13ft"))
class Guard(unittest.TestCase):
def test_single_use(self):
g = JtiGuard(ttl_sec=600)
self.assertTrue(g.claim("a", now=0))
self.assertFalse(g.claim("a", now=10))
self.assertTrue(g.claim("a", now=700))
class Sessions(unittest.TestCase):
def test_round_trip_and_expiry(self):
s = {"sub": "u1", "email": "tony@example.com", "exp": 2000}
v = encode_session(SECRET, s)
self.assertEqual(decode_session(SECRET, v, now_ms=1_000_000)["email"], "tony@example.com")
self.assertIsNone(decode_session(SECRET, v, now_ms=2_000_000))
self.assertIsNone(decode_session("other", v, now_ms=1_000_000))
self.assertIsNone(decode_session(SECRET, v + "x", now_ms=1_000_000))
self.assertIsNone(decode_session(SECRET, "", now_ms=1_000_000))
class Paths(unittest.TestCase):
def test_safe_next(self):
self.assertEqual(safe_next("/x?y=1"), "/x?y=1")
self.assertEqual(safe_next("//evil.com"), "/")
self.assertEqual(safe_next("/\\evil.com"), "/")
self.assertEqual(safe_next("https://evil.com"), "/")
self.assertEqual(safe_next(None), "/")
def test_sign_in_url(self):
self.assertIn("aud=app%3A13ft", otm_sign_in_url("/foo"))
self.assertIn("next=%2Ffoo", otm_sign_in_url("/foo"))
self.assertIn("next=%2F", otm_sign_in_url("//evil"))
if __name__ == "__main__":
unittest.main()