"""Sessions: scoping, expiry, and the invariant that matters.""" import base64 import unittest from sigil import errors, identity, keys, sessions, viewcodes from sigil.store import Store import helpers class Base(unittest.TestCase): def setUp(self): self.store = Store(":memory:") self.private, self.public = keys.generate() secret = identity.seed_bootstrap(self.store, "bigpickle") identity.enrol( self.store, "bigpickle", keys.export_public_b64(self.public), secret ) def issue(self, agent="bigpickle", **kw): return helpers.issue(self.store, self.private, agent, **kw) def agent_session(self, **kw): chal = identity.challenge(self.store, "bigpickle") sig = base64.b64encode(self.private.sign(chal["nonce"].encode())).decode() return identity.verify(self.store, chal["nonce"], sig, **kw) class TestAgentSessions(Base): def test_agent_may_act_as_agent(self): session = sessions.resolve(self.store, self.agent_session()["token"]) self.assertEqual(sessions.assert_may_act_as_agent(session), "bigpickle") def test_expired_session_refused(self): token = self.agent_session(ttl=-1)["token"] with self.assertRaises(errors.SessionExpired): sessions.resolve(self.store, token) def test_revoked_session_refused(self): token = self.agent_session()["token"] sessions.revoke(self.store, token) with self.assertRaises(errors.SessionExpired): sessions.resolve(self.store, token) def test_unknown_token_refused(self): with self.assertRaises(errors.SessionUnknown): sessions.resolve(self.store, "nope") def test_scope_denied(self): session = sessions.resolve(self.store, self.agent_session()["token"]) with self.assertRaises(errors.ScopeDenied): sessions.require_scope(session, "secret:admin") def test_cannot_mint_session_with_invented_scope(self): with self.assertRaises(errors.ScopeDenied): sessions.issue_agent_session(self.store, "bigpickle", scopes=["root:all"]) class TestHumanSessions(Base): def test_human_session_cannot_act_as_agent(self): code = self.issue("bigpickle") view = viewcodes.redeem_code(self.store, code["code"]) session = sessions.resolve(self.store, view["token"]) with self.assertRaises(errors.AgentImpersonationDenied): sessions.assert_may_act_as_agent(session) def test_human_session_is_typed_and_attributed(self): code = self.issue("bigpickle") view = viewcodes.redeem_code(self.store, code["code"]) session = sessions.resolve(self.store, view["token"]) self.assertEqual(session["kind"], "human_via_agent") self.assertEqual(session["on_behalf_of"], "bigpickle") self.assertEqual(session["granted_by"], "bigpickle") self.assertEqual(session["subject"], "bigpickle's human") def test_human_session_can_view(self): code = self.issue("bigpickle") view = viewcodes.redeem_code(self.store, code["code"]) session = sessions.resolve(self.store, view["token"]) sessions.require_scope(session, "view:read") def test_view_session_cannot_be_minted_with_write_scope(self): with self.assertRaises(errors.ScopeDenied): self.issue("bigpickle", scopes=["view:read", "view:write"]) def test_view_session_cannot_be_minted_with_agent_act_scope(self): with self.assertRaises(errors.ScopeDenied): self.issue("bigpickle", scopes=["agent:act"]) def test_long_session_grants_no_extra_capability(self): """Expiry and authority are separate axes.""" short = self.issue("bigpickle") short_session = viewcodes.redeem_code(self.store, short["code"]) # 24 hours of access, redeemed the same way a 5-minute code would be. long_issued = self.issue("bigpickle", code_ttl=300, view_ttl=86400) long_session = viewcodes.redeem_code(self.store, long_issued["code"]) self.assertEqual( sorted(sessions.resolve(self.store, short_session["token"])["scopes"]), sorted(sessions.resolve(self.store, long_session["token"])["scopes"]), ) if __name__ == "__main__": unittest.main()