feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
Build / Build and release (push) Failing after 19s
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
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user