Skip to content

Commit 651707f

Browse files
RahulHeredIvYaNshhh
authored andcommitted
Add FFI binding for OAuthClient C API (#6)
Create ctypes bindings for gopher_auth_oauth_* functions providing GopherOAuthClient class for token exchange, refresh, RFC 8693 token exchange, and RFC 7591 dynamic client registration. In loader.py: - Bind 16 OAuthClient functions + 8 SessionManager + 1 auto-refresh + 3 validation + 2 URL + 3 metadata + 3 HTTP parsing (all specs added for future prompts) - Add to _FUNCTION_SPECS and get_auth_functions() dict New oauth_client.py: - TokenResponse and RegistrationResponse dataclasses - GopherOAuthClient class with handle-based lifecycle - exchange_code(), refresh_token(), token_exchange(), register_client() - Each method reads response via accessors then destroys handle - Context manager support
1 parent 7335637 commit 651707f

4 files changed

Lines changed: 335 additions & 0 deletions

File tree

gopher_mcp_python/ffi/auth/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737
GopherAuthConfig,
3838
)
3939

40+
from gopher_mcp_python.ffi.auth.oauth_client import (
41+
GopherOAuthClient,
42+
TokenResponse,
43+
RegistrationResponse,
44+
)
45+
4046
from gopher_mcp_python.ffi.auth.auth_client import (
4147
GopherAuthClient,
4248
gopher_init_auth_library,

gopher_mcp_python/ffi/auth/loader.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,49 @@ def get_library() -> Optional[ctypes.CDLL]:
270270
("gopher_auth_config_get_int", [c_void_p, c_char_p, POINTER(c_int)], c_int32),
271271
("gopher_auth_config_get_bool", [c_void_p, c_char_p, POINTER(c_bool)], c_int32),
272272
("gopher_auth_config_get_exchange_idps", [c_void_p, POINTER(c_char_p)], c_int32),
273+
# OAuthClient
274+
("gopher_auth_oauth_client_create", [POINTER(c_void_p), c_char_p, c_char_p, c_char_p, c_int], c_int32),
275+
("gopher_auth_oauth_client_destroy", [c_void_p], c_int32),
276+
("gopher_auth_oauth_exchange_code", [c_void_p, c_char_p, c_char_p, c_char_p, POINTER(c_void_p)], c_int32),
277+
("gopher_auth_oauth_refresh_token", [c_void_p, c_char_p, POINTER(c_void_p)], c_int32),
278+
("gopher_auth_oauth_token_exchange", [c_void_p, c_char_p, c_char_p, c_char_p, c_char_p, POINTER(c_void_p)], c_int32),
279+
("gopher_auth_oauth_register_client", [c_void_p, c_char_p, c_char_p, POINTER(c_char_p), c_int, c_char_p, POINTER(c_void_p)], c_int32),
280+
("gopher_auth_token_response_get_access_token", [c_void_p, POINTER(c_char_p)], c_int32),
281+
("gopher_auth_token_response_get_refresh_token", [c_void_p, POINTER(c_char_p)], c_int32),
282+
("gopher_auth_token_response_get_expires_in", [c_void_p, POINTER(c_int64)], c_int32),
283+
("gopher_auth_token_response_get_error", [c_void_p, POINTER(c_char_p)], c_int32),
284+
("gopher_auth_token_response_is_success", [c_void_p], c_bool),
285+
("gopher_auth_token_response_destroy", [c_void_p], c_int32),
286+
("gopher_auth_registration_response_get_client_id", [c_void_p, POINTER(c_char_p)], c_int32),
287+
("gopher_auth_registration_response_get_client_secret", [c_void_p, POINTER(c_char_p)], c_int32),
288+
("gopher_auth_registration_response_is_success", [c_void_p], c_bool),
289+
("gopher_auth_registration_response_destroy", [c_void_p], c_int32),
290+
# SessionManager
291+
("gopher_auth_session_manager_create", [POINTER(c_void_p), c_int], c_int32),
292+
("gopher_auth_session_manager_destroy", [c_void_p], c_int32),
293+
("gopher_auth_session_store_token", [c_void_p, c_char_p, c_char_p, c_char_p, c_int64], c_int32),
294+
("gopher_auth_session_get_access_token", [c_void_p, c_char_p, POINTER(c_char_p)], c_int32),
295+
("gopher_auth_session_get_refresh_token", [c_void_p, c_char_p, POINTER(c_char_p)], c_int32),
296+
("gopher_auth_session_has_valid_token", [c_void_p, c_char_p, POINTER(c_bool)], c_int32),
297+
("gopher_auth_session_cleanup", [c_void_p], c_int32),
298+
("gopher_auth_session_generate_id", [POINTER(c_char_p)], c_int32),
299+
# Auto-Refresh
300+
("gopher_auth_auto_refresh", [c_void_p, c_void_p, c_void_p, c_char_p, POINTER(c_char_p), POINTER(GopherAuthValidationResult)], c_int32),
301+
# Validation
302+
("gopher_auth_validate_idp", [c_char_p, c_char_p, POINTER(c_bool)], c_int32),
303+
("gopher_auth_validate_all_scopes", [c_char_p, c_char_p, POINTER(c_bool)], c_int32),
304+
("gopher_auth_validate_any_scopes", [c_char_p, c_char_p, POINTER(c_bool)], c_int32),
305+
# URL Utils
306+
("gopher_auth_url_encode", [c_char_p, POINTER(c_char_p)], c_int32),
307+
("gopher_auth_url_decode", [c_char_p, POINTER(c_char_p)], c_int32),
308+
# Metadata Builders
309+
("gopher_auth_metadata_build_protected_resource", [c_char_p, c_char_p, c_char_p, POINTER(c_char_p)], c_int32),
310+
("gopher_auth_metadata_build_oauth_server", [c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, POINTER(c_char_p)], c_int32),
311+
("gopher_auth_metadata_build_oidc_discovery", [c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, POINTER(c_char_p)], c_int32),
312+
# HTTP Parsing
313+
("gopher_auth_http_extract_bearer_token", [c_char_p, POINTER(c_char_p)], c_int32),
314+
("gopher_auth_http_extract_method", [c_char_p, POINTER(c_char_p)], c_int32),
315+
("gopher_auth_http_extract_path", [c_char_p, POINTER(c_char_p)], c_int32),
273316
# Utility
274317
("gopher_auth_free_string", [c_char_p], None),
275318
("gopher_auth_generate_www_authenticate", [c_char_p, c_char_p, c_char_p, POINTER(c_char_p)], c_int32),
@@ -421,6 +464,49 @@ def get_auth_functions() -> Dict[str, Any]:
421464
"config_get_int": _get_function("gopher_auth_config_get_int"),
422465
"config_get_bool": _get_function("gopher_auth_config_get_bool"),
423466
"config_get_exchange_idps": _get_function("gopher_auth_config_get_exchange_idps"),
467+
# OAuthClient
468+
"oauth_client_create": _get_function("gopher_auth_oauth_client_create"),
469+
"oauth_client_destroy": _get_function("gopher_auth_oauth_client_destroy"),
470+
"oauth_exchange_code": _get_function("gopher_auth_oauth_exchange_code"),
471+
"oauth_refresh_token": _get_function("gopher_auth_oauth_refresh_token"),
472+
"oauth_token_exchange": _get_function("gopher_auth_oauth_token_exchange"),
473+
"oauth_register_client": _get_function("gopher_auth_oauth_register_client"),
474+
"token_response_get_access_token": _get_function("gopher_auth_token_response_get_access_token"),
475+
"token_response_get_refresh_token": _get_function("gopher_auth_token_response_get_refresh_token"),
476+
"token_response_get_expires_in": _get_function("gopher_auth_token_response_get_expires_in"),
477+
"token_response_get_error": _get_function("gopher_auth_token_response_get_error"),
478+
"token_response_is_success": _get_function("gopher_auth_token_response_is_success"),
479+
"token_response_destroy": _get_function("gopher_auth_token_response_destroy"),
480+
"registration_response_get_client_id": _get_function("gopher_auth_registration_response_get_client_id"),
481+
"registration_response_get_client_secret": _get_function("gopher_auth_registration_response_get_client_secret"),
482+
"registration_response_is_success": _get_function("gopher_auth_registration_response_is_success"),
483+
"registration_response_destroy": _get_function("gopher_auth_registration_response_destroy"),
484+
# SessionManager
485+
"session_manager_create": _get_function("gopher_auth_session_manager_create"),
486+
"session_manager_destroy": _get_function("gopher_auth_session_manager_destroy"),
487+
"session_store_token": _get_function("gopher_auth_session_store_token"),
488+
"session_get_access_token": _get_function("gopher_auth_session_get_access_token"),
489+
"session_get_refresh_token": _get_function("gopher_auth_session_get_refresh_token"),
490+
"session_has_valid_token": _get_function("gopher_auth_session_has_valid_token"),
491+
"session_cleanup": _get_function("gopher_auth_session_cleanup"),
492+
"session_generate_id": _get_function("gopher_auth_session_generate_id"),
493+
# Auto-Refresh
494+
"auto_refresh": _get_function("gopher_auth_auto_refresh"),
495+
# Validation
496+
"validate_idp": _get_function("gopher_auth_validate_idp"),
497+
"validate_all_scopes": _get_function("gopher_auth_validate_all_scopes"),
498+
"validate_any_scopes": _get_function("gopher_auth_validate_any_scopes"),
499+
# URL Utils
500+
"url_encode": _get_function("gopher_auth_url_encode"),
501+
"url_decode": _get_function("gopher_auth_url_decode"),
502+
# Metadata Builders
503+
"metadata_build_protected_resource": _get_function("gopher_auth_metadata_build_protected_resource"),
504+
"metadata_build_oauth_server": _get_function("gopher_auth_metadata_build_oauth_server"),
505+
"metadata_build_oidc_discovery": _get_function("gopher_auth_metadata_build_oidc_discovery"),
506+
# HTTP Parsing
507+
"http_extract_bearer_token": _get_function("gopher_auth_http_extract_bearer_token"),
508+
"http_extract_method": _get_function("gopher_auth_http_extract_method"),
509+
"http_extract_path": _get_function("gopher_auth_http_extract_path"),
424510
# Utility
425511
"free_string": _get_function("gopher_auth_free_string"),
426512
"generate_www_authenticate": _get_function("gopher_auth_generate_www_authenticate"),
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
GopherOAuthClient - FFI wrapper for OAuth HTTP client operations.
3+
4+
Provides token exchange, refresh, RFC 8693 token exchange, and
5+
RFC 7591 dynamic client registration via the native C API.
6+
"""
7+
8+
from ctypes import POINTER, byref, c_char_p, c_int64, c_void_p
9+
from dataclasses import dataclass
10+
from typing import List, Optional
11+
12+
from gopher_mcp_python.ffi.auth.loader import get_auth_functions, is_auth_available, load_library
13+
from gopher_mcp_python.ffi.auth.types import GopherAuthError
14+
15+
16+
@dataclass
17+
class TokenResponse:
18+
"""Token endpoint response."""
19+
access_token: str
20+
refresh_token: Optional[str] = None
21+
expires_in: int = 0
22+
token_type: str = "Bearer"
23+
error: Optional[str] = None
24+
success: bool = False
25+
26+
27+
@dataclass
28+
class RegistrationResponse:
29+
"""Dynamic client registration response."""
30+
client_id: str
31+
client_secret: Optional[str] = None
32+
success: bool = False
33+
error: Optional[str] = None
34+
35+
36+
class GopherOAuthClient:
37+
"""OAuth HTTP client for token operations via native C API."""
38+
39+
def __init__(
40+
self,
41+
token_endpoint: str,
42+
client_id: str,
43+
client_secret: Optional[str] = None,
44+
request_timeout: int = 30,
45+
) -> None:
46+
if not is_auth_available():
47+
load_library()
48+
49+
funcs = get_auth_functions()
50+
create = funcs.get("oauth_client_create")
51+
if not create:
52+
raise RuntimeError("OAuth client functions not available")
53+
54+
self._handle = c_void_p()
55+
result = create(
56+
byref(self._handle),
57+
token_endpoint.encode("utf-8"),
58+
client_id.encode("utf-8"),
59+
client_secret.encode("utf-8") if client_secret else None,
60+
request_timeout,
61+
)
62+
if result != GopherAuthError.SUCCESS:
63+
raise RuntimeError(f"Failed to create OAuth client: error {result}")
64+
self._destroyed = False
65+
66+
def _read_token_response(self, resp_handle: c_void_p) -> TokenResponse:
67+
funcs = get_auth_functions()
68+
success = funcs.get("token_response_is_success")
69+
is_ok = success(resp_handle) if success else False
70+
71+
at_out = c_char_p()
72+
funcs.get("token_response_get_access_token", lambda *a: None)(resp_handle, byref(at_out))
73+
rt_out = c_char_p()
74+
funcs.get("token_response_get_refresh_token", lambda *a: None)(resp_handle, byref(rt_out))
75+
exp_out = c_int64(0)
76+
funcs.get("token_response_get_expires_in", lambda *a: None)(resp_handle, byref(exp_out))
77+
err_out = c_char_p()
78+
funcs.get("token_response_get_error", lambda *a: None)(resp_handle, byref(err_out))
79+
80+
funcs.get("token_response_destroy", lambda *a: None)(resp_handle)
81+
82+
return TokenResponse(
83+
access_token=at_out.value.decode("utf-8") if at_out.value else "",
84+
refresh_token=rt_out.value.decode("utf-8") if rt_out.value else None,
85+
expires_in=exp_out.value,
86+
error=err_out.value.decode("utf-8") if err_out.value and not is_ok else None,
87+
success=is_ok,
88+
)
89+
90+
def exchange_code(self, code: str, redirect_uri: str, code_verifier: Optional[str] = None) -> TokenResponse:
91+
self._ensure_not_destroyed()
92+
funcs = get_auth_functions()
93+
fn = funcs.get("oauth_exchange_code")
94+
if not fn:
95+
return TokenResponse(access_token="", error="ffi_error", success=False)
96+
resp = c_void_p()
97+
err = fn(self._handle, code.encode("utf-8"), redirect_uri.encode("utf-8"),
98+
code_verifier.encode("utf-8") if code_verifier else None, byref(resp))
99+
if err != GopherAuthError.SUCCESS or not resp:
100+
return TokenResponse(access_token="", error="ffi_error", success=False)
101+
return self._read_token_response(resp)
102+
103+
def refresh_token(self, refresh_token: str) -> TokenResponse:
104+
self._ensure_not_destroyed()
105+
funcs = get_auth_functions()
106+
fn = funcs.get("oauth_refresh_token")
107+
if not fn:
108+
return TokenResponse(access_token="", error="ffi_error", success=False)
109+
resp = c_void_p()
110+
err = fn(self._handle, refresh_token.encode("utf-8"), byref(resp))
111+
if err != GopherAuthError.SUCCESS or not resp:
112+
return TokenResponse(access_token="", error="ffi_error", success=False)
113+
return self._read_token_response(resp)
114+
115+
def token_exchange(self, subject_token: str, requested_issuer: str,
116+
audience: Optional[str] = None, scope: Optional[str] = None) -> TokenResponse:
117+
self._ensure_not_destroyed()
118+
funcs = get_auth_functions()
119+
fn = funcs.get("oauth_token_exchange")
120+
if not fn:
121+
return TokenResponse(access_token="", error="ffi_error", success=False)
122+
resp = c_void_p()
123+
err = fn(self._handle, subject_token.encode("utf-8"), requested_issuer.encode("utf-8"),
124+
audience.encode("utf-8") if audience else None,
125+
scope.encode("utf-8") if scope else None, byref(resp))
126+
if err != GopherAuthError.SUCCESS or not resp:
127+
return TokenResponse(access_token="", error="ffi_error", success=False)
128+
return self._read_token_response(resp)
129+
130+
def register_client(self, registration_endpoint: str, client_name: str,
131+
redirect_uris: List[str], scopes: Optional[str] = None) -> RegistrationResponse:
132+
self._ensure_not_destroyed()
133+
funcs = get_auth_functions()
134+
fn = funcs.get("oauth_register_client")
135+
if not fn:
136+
return RegistrationResponse(client_id="", error="ffi_error", success=False)
137+
count = len(redirect_uris)
138+
uris_arr = (c_char_p * count)(*(u.encode("utf-8") for u in redirect_uris))
139+
resp = c_void_p()
140+
err = fn(self._handle, registration_endpoint.encode("utf-8"),
141+
client_name.encode("utf-8"), uris_arr, count,
142+
scopes.encode("utf-8") if scopes else b"openid", byref(resp))
143+
if err != GopherAuthError.SUCCESS or not resp:
144+
return RegistrationResponse(client_id="", error="ffi_error", success=False)
145+
146+
is_ok = funcs.get("registration_response_is_success", lambda *a: False)(resp)
147+
cid_out = c_char_p()
148+
funcs.get("registration_response_get_client_id", lambda *a: None)(resp, byref(cid_out))
149+
cs_out = c_char_p()
150+
funcs.get("registration_response_get_client_secret", lambda *a: None)(resp, byref(cs_out))
151+
funcs.get("registration_response_destroy", lambda *a: None)(resp)
152+
153+
return RegistrationResponse(
154+
client_id=cid_out.value.decode("utf-8") if cid_out.value else "",
155+
client_secret=cs_out.value.decode("utf-8") if cs_out.value else None,
156+
success=is_ok,
157+
)
158+
159+
def get_handle(self) -> c_void_p:
160+
self._ensure_not_destroyed()
161+
return self._handle
162+
163+
def destroy(self) -> None:
164+
if self._destroyed:
165+
return
166+
funcs = get_auth_functions()
167+
d = funcs.get("oauth_client_destroy")
168+
if d and self._handle:
169+
d(self._handle)
170+
self._handle = None
171+
self._destroyed = True
172+
173+
def is_destroyed(self) -> bool:
174+
return self._destroyed
175+
176+
def __enter__(self) -> "GopherOAuthClient":
177+
return self
178+
179+
def __exit__(self, *args: object) -> None:
180+
self.destroy()
181+
182+
def _ensure_not_destroyed(self) -> None:
183+
if self._destroyed:
184+
raise RuntimeError("GopherOAuthClient has been destroyed")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for GopherOAuthClient FFI binding."""
2+
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.oauth_client import GopherOAuthClient
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 TestOAuthClient:
16+
def test_create_with_all_params(self):
17+
client = GopherOAuthClient("http://kc:8080/token", "cid", "cs", 30)
18+
assert not client.is_destroyed()
19+
client.destroy()
20+
21+
def test_create_without_secret(self):
22+
client = GopherOAuthClient("http://kc:8080/token", "cid")
23+
assert not client.is_destroyed()
24+
client.destroy()
25+
26+
def test_error_for_unreachable_server(self):
27+
client = GopherOAuthClient("http://192.0.2.1:1/token", "cid", "cs", 1)
28+
resp = client.exchange_code("code", "http://cb")
29+
assert not resp.success
30+
client.destroy()
31+
32+
def test_refresh_unreachable(self):
33+
client = GopherOAuthClient("http://192.0.2.1:1/token", "cid", "cs", 1)
34+
resp = client.refresh_token("rt")
35+
assert not resp.success
36+
client.destroy()
37+
38+
def test_token_exchange_unreachable(self):
39+
client = GopherOAuthClient("http://192.0.2.1:1/token", "cid", "cs", 1)
40+
resp = client.token_exchange("st", "google")
41+
assert not resp.success
42+
client.destroy()
43+
44+
def test_destroy_lifecycle(self):
45+
client = GopherOAuthClient("http://kc:8080/token", "cid", "cs", 5)
46+
client.destroy()
47+
assert client.is_destroyed()
48+
49+
def test_call_after_destroy_raises(self):
50+
client = GopherOAuthClient("http://kc:8080/token", "cid", "cs", 5)
51+
client.destroy()
52+
with pytest.raises(RuntimeError, match="destroyed"):
53+
client.exchange_code("code", "http://cb")
54+
55+
def test_double_destroy_safe(self):
56+
client = GopherOAuthClient("http://kc:8080/token", "cid", "cs", 5)
57+
client.destroy()
58+
client.destroy()
59+
assert client.is_destroyed()

0 commit comments

Comments
 (0)