Skip to content

Commit 967c079

Browse files
RahulHeredIvYaNshhh
authored andcommitted
Add FFI binding for SessionManager C API (#6)
Create ctypes bindings for gopher_auth_session_* functions providing GopherSessionManager class for per-client session management with token storage, expiry tracking, and secure ID generation. New session_manager.py: - GopherSessionManager with handle-based lifecycle - store_token(), get_access_token(), get_refresh_token() - has_valid_token() with 5s buffer, cleanup() for expired sessions - Static generate_session_id() returns 32-char hex string - Context manager support Changes: - Create GopherSessionManager class - Add 9 unit tests: store/retrieve, valid/expired, unknown session, hex ID, unique IDs, cleanup, destroy lifecycle
1 parent 651707f commit 967c079

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
GopherSessionManager - FFI wrapper for per-client session management.
3+
4+
Thread-safe session manager with OAuth token storage, expiry tracking,
5+
and secure session ID generation.
6+
"""
7+
8+
from ctypes import POINTER, byref, c_bool, c_char_p, c_void_p
9+
from typing import Optional
10+
11+
from gopher_mcp_python.ffi.auth.loader import get_auth_functions, is_auth_available, load_library
12+
from gopher_mcp_python.ffi.auth.types import GopherAuthError
13+
14+
15+
class GopherSessionManager:
16+
"""Per-client session manager via native C API."""
17+
18+
def __init__(self, timeout_seconds: int = 300) -> None:
19+
if not is_auth_available():
20+
load_library()
21+
funcs = get_auth_functions()
22+
create = funcs.get("session_manager_create")
23+
if not create:
24+
raise RuntimeError("Session manager functions not available")
25+
self._handle = c_void_p()
26+
result = create(byref(self._handle), timeout_seconds)
27+
if result != GopherAuthError.SUCCESS:
28+
raise RuntimeError(f"Failed to create session manager: error {result}")
29+
self._destroyed = False
30+
31+
def store_token(self, session_id: str, access_token: str,
32+
refresh_token: str, expires_in: int) -> None:
33+
self._ensure_not_destroyed()
34+
funcs = get_auth_functions()
35+
fn = funcs.get("session_store_token")
36+
if fn:
37+
fn(self._handle, session_id.encode("utf-8"), access_token.encode("utf-8"),
38+
refresh_token.encode("utf-8"), expires_in)
39+
40+
def get_access_token(self, session_id: str) -> Optional[str]:
41+
self._ensure_not_destroyed()
42+
funcs = get_auth_functions()
43+
fn = funcs.get("session_get_access_token")
44+
if not fn:
45+
return None
46+
out = c_char_p()
47+
result = fn(self._handle, session_id.encode("utf-8"), byref(out))
48+
if result != GopherAuthError.SUCCESS:
49+
return None
50+
if out.value:
51+
val = out.value.decode("utf-8")
52+
free = funcs.get("free_string")
53+
if free:
54+
free(out)
55+
return val
56+
return None
57+
58+
def get_refresh_token(self, session_id: str) -> Optional[str]:
59+
self._ensure_not_destroyed()
60+
funcs = get_auth_functions()
61+
fn = funcs.get("session_get_refresh_token")
62+
if not fn:
63+
return None
64+
out = c_char_p()
65+
result = fn(self._handle, session_id.encode("utf-8"), byref(out))
66+
if result != GopherAuthError.SUCCESS:
67+
return None
68+
if out.value:
69+
val = out.value.decode("utf-8")
70+
free = funcs.get("free_string")
71+
if free:
72+
free(out)
73+
return val
74+
return None
75+
76+
def has_valid_token(self, session_id: str) -> bool:
77+
self._ensure_not_destroyed()
78+
funcs = get_auth_functions()
79+
fn = funcs.get("session_has_valid_token")
80+
if not fn:
81+
return False
82+
out = c_bool(False)
83+
fn(self._handle, session_id.encode("utf-8"), byref(out))
84+
return out.value
85+
86+
def cleanup(self) -> None:
87+
self._ensure_not_destroyed()
88+
funcs = get_auth_functions()
89+
fn = funcs.get("session_cleanup")
90+
if fn:
91+
fn(self._handle)
92+
93+
@staticmethod
94+
def generate_session_id() -> str:
95+
if not is_auth_available():
96+
load_library()
97+
funcs = get_auth_functions()
98+
fn = funcs.get("session_generate_id")
99+
if not fn:
100+
raise RuntimeError("Function not available")
101+
out = c_char_p()
102+
result = fn(byref(out))
103+
if result != GopherAuthError.SUCCESS or not out.value:
104+
raise RuntimeError("Failed to generate session ID")
105+
val = out.value.decode("utf-8")
106+
free = funcs.get("free_string")
107+
if free:
108+
free(out)
109+
return val
110+
111+
def get_handle(self) -> c_void_p:
112+
self._ensure_not_destroyed()
113+
return self._handle
114+
115+
def destroy(self) -> None:
116+
if self._destroyed:
117+
return
118+
funcs = get_auth_functions()
119+
d = funcs.get("session_manager_destroy")
120+
if d and self._handle:
121+
d(self._handle)
122+
self._handle = None
123+
self._destroyed = True
124+
125+
def is_destroyed(self) -> bool:
126+
return self._destroyed
127+
128+
def __enter__(self) -> "GopherSessionManager":
129+
return self
130+
131+
def __exit__(self, *args: object) -> None:
132+
self.destroy()
133+
134+
def _ensure_not_destroyed(self) -> None:
135+
if self._destroyed:
136+
raise RuntimeError("GopherSessionManager has been destroyed")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for GopherSessionManager FFI binding."""
2+
import time
3+
import pytest
4+
from gopher_mcp_python.ffi.auth.loader import is_auth_available
5+
6+
pytestmark = pytest.mark.skipif(not is_auth_available(), reason="Native library not available")
7+
8+
from gopher_mcp_python.ffi.auth.session_manager import GopherSessionManager
9+
from gopher_mcp_python.ffi.auth.auth_client import gopher_init_auth_library
10+
11+
@pytest.fixture(autouse=True)
12+
def init_lib():
13+
gopher_init_auth_library()
14+
15+
class TestSessionManager:
16+
def test_store_and_retrieve(self):
17+
mgr = GopherSessionManager(300)
18+
mgr.store_token("s1", "access", "refresh", 3600)
19+
assert mgr.get_access_token("s1") == "access"
20+
assert mgr.get_refresh_token("s1") == "refresh"
21+
mgr.destroy()
22+
23+
def test_has_valid_token_true(self):
24+
mgr = GopherSessionManager(300)
25+
mgr.store_token("s1", "tok", "ref", 3600)
26+
assert mgr.has_valid_token("s1") is True
27+
mgr.destroy()
28+
29+
def test_has_valid_token_expired(self):
30+
mgr = GopherSessionManager(300)
31+
mgr.store_token("s1", "tok", "ref", -10)
32+
assert mgr.has_valid_token("s1") is False
33+
mgr.destroy()
34+
35+
def test_unknown_session(self):
36+
mgr = GopherSessionManager(300)
37+
assert mgr.get_access_token("nonexistent") is None
38+
mgr.destroy()
39+
40+
def test_generate_id_hex(self):
41+
sid = GopherSessionManager.generate_session_id()
42+
assert len(sid) == 32
43+
assert all(c in "0123456789abcdef" for c in sid)
44+
45+
def test_generate_id_unique(self):
46+
ids = {GopherSessionManager.generate_session_id() for _ in range(10)}
47+
assert len(ids) == 10
48+
49+
def test_cleanup(self):
50+
mgr = GopherSessionManager(1)
51+
mgr.store_token("s1", "t1", "r1", 3600)
52+
time.sleep(2.1)
53+
mgr.store_token("s2", "t2", "r2", 3600)
54+
mgr.cleanup()
55+
assert mgr.get_access_token("s1") is None
56+
assert mgr.get_access_token("s2") == "t2"
57+
mgr.destroy()
58+
59+
def test_destroy(self):
60+
mgr = GopherSessionManager(300)
61+
mgr.destroy()
62+
assert mgr.is_destroyed()
63+
64+
def test_double_destroy(self):
65+
mgr = GopherSessionManager(300)
66+
mgr.destroy()
67+
mgr.destroy()
68+
assert mgr.is_destroyed()

0 commit comments

Comments
 (0)