"""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()