Build / Build and release (push) Failing after 19s
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
96 lines
3.8 KiB
Python
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()
|