gopher-mcp-python 0.1.2.1__tar.gz → 0.1.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/PKG-INFO +1 -1
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/__init__.py +7 -2
- gopher_mcp_python-0.1.15/gopher_mcp_python/auth/__init__.py +31 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/auth/errors.py +48 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/auth/gopher_auth.py +255 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/auth/scope_helpers.py +34 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/__init__.py +12 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/auth_client.py +54 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/auto_refresh.py +89 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/config_loader.py +255 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/loader.py +854 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/oauth_client.py +232 -0
- gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/session_manager.py +146 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/types.py +11 -1
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/library.py +12 -8
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/PKG-INFO +1 -1
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/SOURCES.txt +9 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/pyproject.toml +1 -1
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_ffi.py +23 -18
- gopher_mcp_python-0.1.15/tests/test_gopher_auth.py +154 -0
- gopher_mcp_python-0.1.2.1/gopher_mcp_python/ffi/auth/loader.py +0 -404
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/LICENSE +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/README.md +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/agent.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/config.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/errors.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/__init__.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/validation_options.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/result.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/server_config.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/dependency_links.txt +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/requires.txt +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/top_level.txt +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/setup.cfg +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/setup.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_config.py +0 -0
- {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_result.py +0 -0
|
@@ -25,7 +25,12 @@ Example:
|
|
|
25
25
|
from gopher_mcp_python.agent import GopherAgent
|
|
26
26
|
from gopher_mcp_python.config import GopherAgentConfig, GopherAgentConfigBuilder
|
|
27
27
|
from gopher_mcp_python.result import AgentResult, AgentResultStatus, AgentResultBuilder
|
|
28
|
-
from gopher_mcp_python.errors import
|
|
28
|
+
from gopher_mcp_python.errors import (
|
|
29
|
+
AgentError,
|
|
30
|
+
ApiKeyError,
|
|
31
|
+
ConnectionError,
|
|
32
|
+
TimeoutError,
|
|
33
|
+
)
|
|
29
34
|
from gopher_mcp_python.server_config import ServerConfig
|
|
30
35
|
from gopher_mcp_python.ffi import GopherOrchLibrary
|
|
31
36
|
|
|
@@ -45,7 +50,7 @@ from gopher_mcp_python.ffi.auth import (
|
|
|
45
50
|
is_auth_available,
|
|
46
51
|
)
|
|
47
52
|
|
|
48
|
-
__version__ = "0.1.
|
|
53
|
+
__version__ = "0.1.15"
|
|
49
54
|
|
|
50
55
|
__all__ = [
|
|
51
56
|
# Main classes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth Module - Reusable OAuth/JWT authentication for Python MCP servers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from gopher_mcp_python.auth.gopher_auth import GopherAuth
|
|
6
|
+
from gopher_mcp_python.auth.errors import (
|
|
7
|
+
GopherAuthError,
|
|
8
|
+
TokenValidationError,
|
|
9
|
+
InsufficientScopesError,
|
|
10
|
+
JwksError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
TokenExchangeError,
|
|
13
|
+
)
|
|
14
|
+
from gopher_mcp_python.auth.scope_helpers import (
|
|
15
|
+
has_scope,
|
|
16
|
+
has_all_scopes,
|
|
17
|
+
has_any_scope,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GopherAuth",
|
|
22
|
+
"GopherAuthError",
|
|
23
|
+
"TokenValidationError",
|
|
24
|
+
"InsufficientScopesError",
|
|
25
|
+
"JwksError",
|
|
26
|
+
"ConfigurationError",
|
|
27
|
+
"TokenExchangeError",
|
|
28
|
+
"has_scope",
|
|
29
|
+
"has_all_scopes",
|
|
30
|
+
"has_any_scope",
|
|
31
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Custom error classes for the auth module."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GopherAuthError(Exception):
|
|
5
|
+
"""Base error for auth operations."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenValidationError(GopherAuthError):
|
|
11
|
+
"""Token validation failed."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, error_code: int = 0):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.error_code = error_code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InsufficientScopesError(GopherAuthError):
|
|
19
|
+
"""Required scopes not present."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, required_scopes: list, actual_scopes: list, message: str = ""):
|
|
22
|
+
msg = message or (
|
|
23
|
+
f"Insufficient scopes: required {required_scopes}, actual {actual_scopes}"
|
|
24
|
+
)
|
|
25
|
+
super().__init__(msg)
|
|
26
|
+
self.required_scopes = required_scopes
|
|
27
|
+
self.actual_scopes = actual_scopes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JwksError(GopherAuthError):
|
|
31
|
+
"""JWKS fetch or parsing failed."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigurationError(GopherAuthError):
|
|
37
|
+
"""Invalid configuration."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TokenExchangeError(GopherAuthError):
|
|
43
|
+
"""Token exchange failed."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, error_code: str = "", error_description: str = ""):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.error_code = error_code
|
|
48
|
+
self.error_description = error_description
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GopherAuth - Reusable auth module for Python.
|
|
3
|
+
|
|
4
|
+
Provides OAuth 2.0 / JWT authentication via native FFI bindings.
|
|
5
|
+
Matches the JS SDK's GopherAuth class API surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from gopher_mcp_python.ffi.auth.auth_client import (
|
|
11
|
+
GopherAuthClient,
|
|
12
|
+
gopher_init_auth_library,
|
|
13
|
+
gopher_shutdown_auth_library,
|
|
14
|
+
gopher_generate_www_authenticate_header_v2,
|
|
15
|
+
)
|
|
16
|
+
from gopher_mcp_python.ffi.auth.config_loader import GopherAuthConfig
|
|
17
|
+
from gopher_mcp_python.ffi.auth.oauth_client import GopherOAuthClient, TokenResponse
|
|
18
|
+
from gopher_mcp_python.ffi.auth.session_manager import GopherSessionManager
|
|
19
|
+
from gopher_mcp_python.ffi.auth.loader import (
|
|
20
|
+
gopher_auth_build_protected_resource_metadata,
|
|
21
|
+
)
|
|
22
|
+
from gopher_mcp_python.ffi.auth.types import TokenPayload, GopherAuthContext
|
|
23
|
+
|
|
24
|
+
from gopher_mcp_python.auth.errors import (
|
|
25
|
+
ConfigurationError,
|
|
26
|
+
TokenValidationError,
|
|
27
|
+
InsufficientScopesError,
|
|
28
|
+
TokenExchangeError,
|
|
29
|
+
)
|
|
30
|
+
from gopher_mcp_python.auth.scope_helpers import (
|
|
31
|
+
has_scope,
|
|
32
|
+
has_all_scopes,
|
|
33
|
+
has_any_scope,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GopherAuth:
|
|
38
|
+
"""Reusable auth module wrapping native FFI bindings."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config_path: Optional[str] = None,
|
|
43
|
+
config: Optional[Dict[str, Any]] = None,
|
|
44
|
+
auth_disabled: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._config_path = config_path
|
|
47
|
+
self._config_dict = config
|
|
48
|
+
self._auth_disabled = auth_disabled
|
|
49
|
+
self._config: Optional[GopherAuthConfig] = None
|
|
50
|
+
self._auth_client: Optional[GopherAuthClient] = None
|
|
51
|
+
self._oauth_client: Optional[GopherOAuthClient] = None
|
|
52
|
+
self._session_manager: Optional[GopherSessionManager] = None
|
|
53
|
+
self._initialized = False
|
|
54
|
+
|
|
55
|
+
def initialize(self) -> None:
|
|
56
|
+
"""Initialize the auth module. Must be called before any other method."""
|
|
57
|
+
if self._initialized:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if self._auth_disabled:
|
|
61
|
+
self._initialized = True
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
gopher_init_auth_library()
|
|
65
|
+
|
|
66
|
+
if self._config_path:
|
|
67
|
+
self._config = GopherAuthConfig.load_file(self._config_path)
|
|
68
|
+
elif self._config_dict:
|
|
69
|
+
pairs: Dict[str, str] = {}
|
|
70
|
+
mapping = {
|
|
71
|
+
"auth_server_url": "auth_server_url",
|
|
72
|
+
"client_id": "client_id",
|
|
73
|
+
"client_secret": "client_secret",
|
|
74
|
+
"audience": "audience",
|
|
75
|
+
"issuer": "issuer",
|
|
76
|
+
"jwks_uri": "jwks_uri",
|
|
77
|
+
"oauth_authorize_url": "oauth_authorize_url",
|
|
78
|
+
"oauth_token_url": "oauth_token_url",
|
|
79
|
+
"server_url": "server_url",
|
|
80
|
+
"allowed_scopes": "allowed_scopes",
|
|
81
|
+
"exchange_idps": "exchange_idps",
|
|
82
|
+
"host": "host",
|
|
83
|
+
"port": "port",
|
|
84
|
+
}
|
|
85
|
+
for py_key, cfg_key in mapping.items():
|
|
86
|
+
val = self._config_dict.get(py_key)
|
|
87
|
+
if val is not None:
|
|
88
|
+
pairs[cfg_key] = str(val)
|
|
89
|
+
self._config = GopherAuthConfig.load_from_pairs(pairs)
|
|
90
|
+
else:
|
|
91
|
+
raise ConfigurationError("Either config_path or config must be provided")
|
|
92
|
+
|
|
93
|
+
# Create auth client
|
|
94
|
+
jwks_uri = self._config.get_string("jwks_uri")
|
|
95
|
+
issuer = self._config.get_string("issuer")
|
|
96
|
+
if jwks_uri and issuer:
|
|
97
|
+
self._auth_client = GopherAuthClient(jwks_uri, issuer)
|
|
98
|
+
cache_dur = self._config.get_string("jwks_cache_duration")
|
|
99
|
+
if cache_dur:
|
|
100
|
+
self._auth_client.set_option("cache_duration", cache_dur)
|
|
101
|
+
|
|
102
|
+
# Create OAuth client
|
|
103
|
+
token_endpoint = self._config.get_string("token_endpoint")
|
|
104
|
+
client_id = self._config.get_string("client_id")
|
|
105
|
+
client_secret = self._config.get_string("client_secret")
|
|
106
|
+
if token_endpoint and client_id:
|
|
107
|
+
self._oauth_client = GopherOAuthClient(
|
|
108
|
+
token_endpoint, client_id, client_secret or None, 30
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._session_manager = GopherSessionManager(300)
|
|
112
|
+
self._initialized = True
|
|
113
|
+
|
|
114
|
+
def shutdown(self) -> None:
|
|
115
|
+
"""Shutdown and release all native resources."""
|
|
116
|
+
if self._session_manager:
|
|
117
|
+
self._session_manager.destroy()
|
|
118
|
+
if self._oauth_client:
|
|
119
|
+
self._oauth_client.destroy()
|
|
120
|
+
if self._auth_client:
|
|
121
|
+
self._auth_client.destroy()
|
|
122
|
+
if self._config:
|
|
123
|
+
self._config.destroy()
|
|
124
|
+
self._session_manager = None
|
|
125
|
+
self._oauth_client = None
|
|
126
|
+
self._auth_client = None
|
|
127
|
+
self._config = None
|
|
128
|
+
if self._initialized and not self._auth_disabled:
|
|
129
|
+
gopher_shutdown_auth_library()
|
|
130
|
+
self._initialized = False
|
|
131
|
+
|
|
132
|
+
def validate_token(
|
|
133
|
+
self,
|
|
134
|
+
token: str,
|
|
135
|
+
required_scopes: Optional[List[str]] = None,
|
|
136
|
+
) -> TokenPayload:
|
|
137
|
+
"""Validate a JWT token. Raises on failure."""
|
|
138
|
+
self._ensure_initialized()
|
|
139
|
+
if not self._auth_client:
|
|
140
|
+
raise TokenValidationError("Auth client not initialized")
|
|
141
|
+
|
|
142
|
+
result = self._auth_client.validate_token(token)
|
|
143
|
+
if not result or not result.valid:
|
|
144
|
+
raise TokenValidationError(
|
|
145
|
+
result.error_message if result else "Token validation failed",
|
|
146
|
+
result.error_code if result else 0,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
payload = self._auth_client.extract_payload(token)
|
|
150
|
+
if not payload:
|
|
151
|
+
raise TokenValidationError("Failed to extract payload")
|
|
152
|
+
|
|
153
|
+
if required_scopes:
|
|
154
|
+
token_scopes = [s for s in payload.scopes.split(" ") if s]
|
|
155
|
+
missing = [s for s in required_scopes if s not in token_scopes]
|
|
156
|
+
if missing:
|
|
157
|
+
raise InsufficientScopesError(required_scopes, token_scopes)
|
|
158
|
+
|
|
159
|
+
return payload
|
|
160
|
+
|
|
161
|
+
def has_scope(self, payload: TokenPayload, scope: str) -> bool:
|
|
162
|
+
ctx = GopherAuthContext(
|
|
163
|
+
user_id=payload.subject,
|
|
164
|
+
scopes=payload.scopes,
|
|
165
|
+
audience=payload.audience or "",
|
|
166
|
+
token_expiry=payload.expiration or 0,
|
|
167
|
+
authenticated=True,
|
|
168
|
+
)
|
|
169
|
+
return has_scope(ctx, scope)
|
|
170
|
+
|
|
171
|
+
def has_all_scopes(self, payload: TokenPayload, scopes: List[str]) -> bool:
|
|
172
|
+
ctx = GopherAuthContext(
|
|
173
|
+
user_id=payload.subject,
|
|
174
|
+
scopes=payload.scopes,
|
|
175
|
+
audience=payload.audience or "",
|
|
176
|
+
token_expiry=payload.expiration or 0,
|
|
177
|
+
authenticated=True,
|
|
178
|
+
)
|
|
179
|
+
return has_all_scopes(ctx, scopes)
|
|
180
|
+
|
|
181
|
+
def has_any_scope(self, payload: TokenPayload, scopes: List[str]) -> bool:
|
|
182
|
+
ctx = GopherAuthContext(
|
|
183
|
+
user_id=payload.subject,
|
|
184
|
+
scopes=payload.scopes,
|
|
185
|
+
audience=payload.audience or "",
|
|
186
|
+
token_expiry=payload.expiration or 0,
|
|
187
|
+
authenticated=True,
|
|
188
|
+
)
|
|
189
|
+
return has_any_scope(ctx, scopes)
|
|
190
|
+
|
|
191
|
+
def exchange_token(
|
|
192
|
+
self,
|
|
193
|
+
subject_token: str,
|
|
194
|
+
requested_issuer: str,
|
|
195
|
+
audience: Optional[str] = None,
|
|
196
|
+
scope: Optional[str] = None,
|
|
197
|
+
) -> TokenResponse:
|
|
198
|
+
"""Exchange a token (RFC 8693). Raises on failure."""
|
|
199
|
+
self._ensure_initialized()
|
|
200
|
+
if not self._oauth_client:
|
|
201
|
+
raise TokenExchangeError("OAuth client not initialized")
|
|
202
|
+
result = self._oauth_client.token_exchange(
|
|
203
|
+
subject_token, requested_issuer, audience, scope
|
|
204
|
+
)
|
|
205
|
+
if not result.success:
|
|
206
|
+
raise TokenExchangeError(
|
|
207
|
+
result.error or "Token exchange failed",
|
|
208
|
+
result.error or "",
|
|
209
|
+
)
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
def get_protected_resource_metadata(self) -> dict:
|
|
213
|
+
server_url = self._config.get_string("server_url") if self._config else ""
|
|
214
|
+
scopes = self._config.get_string("allowed_scopes") if self._config else ""
|
|
215
|
+
return gopher_auth_build_protected_resource_metadata(
|
|
216
|
+
f"{server_url}/mcp", server_url, scopes or None
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def get_www_authenticate_header(
|
|
220
|
+
self,
|
|
221
|
+
error: str = "",
|
|
222
|
+
error_description: str = "",
|
|
223
|
+
) -> str:
|
|
224
|
+
server_url = self._config.get_string("server_url") if self._config else ""
|
|
225
|
+
resource_metadata = f"{server_url}/.well-known/oauth-protected-resource"
|
|
226
|
+
return (
|
|
227
|
+
gopher_generate_www_authenticate_header_v2(
|
|
228
|
+
server_url, resource_metadata, "", error, error_description
|
|
229
|
+
)
|
|
230
|
+
or "Bearer"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def get_token_endpoint(self) -> str:
|
|
234
|
+
return self._config.get_string("token_endpoint") if self._config else ""
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def native_config(self) -> Optional[GopherAuthConfig]:
|
|
238
|
+
return self._config
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def is_disabled(self) -> bool:
|
|
242
|
+
return self._auth_disabled
|
|
243
|
+
|
|
244
|
+
def __enter__(self) -> "GopherAuth":
|
|
245
|
+
self.initialize()
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def __exit__(self, *args: object) -> None:
|
|
249
|
+
self.shutdown()
|
|
250
|
+
|
|
251
|
+
def _ensure_initialized(self) -> None:
|
|
252
|
+
if not self._initialized:
|
|
253
|
+
raise ConfigurationError(
|
|
254
|
+
"GopherAuth not initialized. Call initialize() first."
|
|
255
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Scope validation helpers for per-request usage."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from gopher_mcp_python.ffi.auth.types import GopherAuthContext
|
|
6
|
+
from gopher_mcp_python.ffi.auth.loader import (
|
|
7
|
+
gopher_auth_validate_all_scopes,
|
|
8
|
+
gopher_auth_validate_any_scopes,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def has_scope(context: Optional[GopherAuthContext], scope: str) -> bool:
|
|
13
|
+
"""Check if context has a specific scope."""
|
|
14
|
+
if not context or not context.scopes:
|
|
15
|
+
return False
|
|
16
|
+
return gopher_auth_validate_all_scopes(context.scopes, scope)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def has_all_scopes(context: Optional[GopherAuthContext], scopes: List[str]) -> bool:
|
|
20
|
+
"""Check if context has ALL required scopes (AND logic)."""
|
|
21
|
+
if not context or not context.scopes:
|
|
22
|
+
return len(scopes) == 0
|
|
23
|
+
if not scopes:
|
|
24
|
+
return True
|
|
25
|
+
return gopher_auth_validate_all_scopes(context.scopes, " ".join(scopes))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def has_any_scope(context: Optional[GopherAuthContext], scopes: List[str]) -> bool:
|
|
29
|
+
"""Check if context has ANY of the required scopes (OR logic)."""
|
|
30
|
+
if not context or not context.scopes:
|
|
31
|
+
return len(scopes) == 0
|
|
32
|
+
if not scopes:
|
|
33
|
+
return True
|
|
34
|
+
return gopher_auth_validate_any_scopes(context.scopes, " ".join(scopes))
|
{gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/__init__.py
RENAMED
|
@@ -33,6 +33,16 @@ from gopher_mcp_python.ffi.auth.validation_options import (
|
|
|
33
33
|
gopher_create_validation_options,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
+
from gopher_mcp_python.ffi.auth.config_loader import (
|
|
37
|
+
GopherAuthConfig,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from gopher_mcp_python.ffi.auth.oauth_client import (
|
|
41
|
+
GopherOAuthClient,
|
|
42
|
+
TokenResponse,
|
|
43
|
+
RegistrationResponse,
|
|
44
|
+
)
|
|
45
|
+
|
|
36
46
|
from gopher_mcp_python.ffi.auth.auth_client import (
|
|
37
47
|
GopherAuthClient,
|
|
38
48
|
gopher_init_auth_library,
|
|
@@ -71,6 +81,8 @@ __all__ = [
|
|
|
71
81
|
# Validation options
|
|
72
82
|
"GopherValidationOptions",
|
|
73
83
|
"gopher_create_validation_options",
|
|
84
|
+
# Config loader
|
|
85
|
+
"GopherAuthConfig",
|
|
74
86
|
# Auth client
|
|
75
87
|
"GopherAuthClient",
|
|
76
88
|
"gopher_init_auth_library",
|
{gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/auth_client.py
RENAMED
|
@@ -423,12 +423,22 @@ class GopherAuthClient:
|
|
|
423
423
|
if get_exp(payload_handle, byref(exp_value)) == GopherAuthError.SUCCESS:
|
|
424
424
|
expiration = exp_value.value
|
|
425
425
|
|
|
426
|
+
# Extract extended claims via payload_get_claim
|
|
427
|
+
email = self._get_payload_claim(payload_handle, "email")
|
|
428
|
+
name_val = self._get_payload_claim(payload_handle, "name")
|
|
429
|
+
organization_id = self._get_payload_claim(payload_handle, "organization_id")
|
|
430
|
+
server_id = self._get_payload_claim(payload_handle, "server_id")
|
|
431
|
+
|
|
426
432
|
return TokenPayload(
|
|
427
433
|
subject=subject or "",
|
|
428
434
|
scopes=scopes or "",
|
|
429
435
|
audience=audience,
|
|
430
436
|
expiration=expiration,
|
|
431
437
|
issuer=issuer,
|
|
438
|
+
email=email,
|
|
439
|
+
name=name_val,
|
|
440
|
+
organization_id=organization_id,
|
|
441
|
+
server_id=server_id,
|
|
432
442
|
)
|
|
433
443
|
finally:
|
|
434
444
|
# Clean up payload handle
|
|
@@ -472,6 +482,45 @@ class GopherAuthClient:
|
|
|
472
482
|
|
|
473
483
|
return None
|
|
474
484
|
|
|
485
|
+
def _get_payload_claim(
|
|
486
|
+
self,
|
|
487
|
+
payload_handle: c_void_p,
|
|
488
|
+
claim_name: str,
|
|
489
|
+
) -> Optional[str]:
|
|
490
|
+
"""
|
|
491
|
+
Get a custom claim from payload by name.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
payload_handle: The payload handle.
|
|
495
|
+
claim_name: The claim name to extract.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
The claim value, or None if not present.
|
|
499
|
+
"""
|
|
500
|
+
funcs = get_auth_functions()
|
|
501
|
+
get_claim = funcs.get("payload_get_claim")
|
|
502
|
+
if get_claim is None:
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
value_out = c_char_p()
|
|
506
|
+
result = get_claim(
|
|
507
|
+
payload_handle,
|
|
508
|
+
claim_name.encode("utf-8"),
|
|
509
|
+
byref(value_out),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if result != GopherAuthError.SUCCESS:
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
if value_out.value:
|
|
516
|
+
value = value_out.value.decode("utf-8")
|
|
517
|
+
free_string = funcs.get("free_string")
|
|
518
|
+
if free_string:
|
|
519
|
+
free_string(value_out)
|
|
520
|
+
return value
|
|
521
|
+
|
|
522
|
+
return None
|
|
523
|
+
|
|
475
524
|
def validate_and_extract(
|
|
476
525
|
self,
|
|
477
526
|
token: str,
|
|
@@ -515,6 +564,11 @@ class GopherAuthClient:
|
|
|
515
564
|
self._handle = None
|
|
516
565
|
self._destroyed = True
|
|
517
566
|
|
|
567
|
+
def get_handle(self) -> c_void_p:
|
|
568
|
+
"""Get the native handle (for internal use by auto-refresh)."""
|
|
569
|
+
self._ensure_not_destroyed()
|
|
570
|
+
return self._handle
|
|
571
|
+
|
|
518
572
|
def is_destroyed(self) -> bool:
|
|
519
573
|
"""
|
|
520
574
|
Check if this client has been destroyed.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-Refresh - Combines session lookup, token refresh, and re-validation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from ctypes import POINTER, byref, c_char_p
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from gopher_mcp_python.ffi.auth.loader import (
|
|
10
|
+
GopherAuthValidationResult,
|
|
11
|
+
get_auth_functions,
|
|
12
|
+
)
|
|
13
|
+
from gopher_mcp_python.ffi.auth.auth_client import GopherAuthClient
|
|
14
|
+
from gopher_mcp_python.ffi.auth.oauth_client import GopherOAuthClient
|
|
15
|
+
from gopher_mcp_python.ffi.auth.session_manager import GopherSessionManager
|
|
16
|
+
from gopher_mcp_python.ffi.auth.types import GopherAuthError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AutoRefreshResult:
|
|
21
|
+
"""Result of auto-refresh operation."""
|
|
22
|
+
|
|
23
|
+
valid: bool
|
|
24
|
+
new_access_token: Optional[str] = None
|
|
25
|
+
error_code: int = 0
|
|
26
|
+
error_message: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def gopher_auth_auto_refresh(
|
|
30
|
+
auth_client: GopherAuthClient,
|
|
31
|
+
oauth_client: GopherOAuthClient,
|
|
32
|
+
session_manager: GopherSessionManager,
|
|
33
|
+
session_id: str,
|
|
34
|
+
) -> AutoRefreshResult:
|
|
35
|
+
"""
|
|
36
|
+
Auto-refresh: validate token, refresh if expired, re-validate.
|
|
37
|
+
|
|
38
|
+
If the token is still valid, new_access_token is None.
|
|
39
|
+
If refreshed, new_access_token contains the new token.
|
|
40
|
+
"""
|
|
41
|
+
funcs = get_auth_functions()
|
|
42
|
+
fn = funcs.get("auto_refresh")
|
|
43
|
+
if not fn:
|
|
44
|
+
return AutoRefreshResult(
|
|
45
|
+
valid=False,
|
|
46
|
+
error_code=GopherAuthError.NOT_INITIALIZED,
|
|
47
|
+
error_message="Auto-refresh function not available",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
token_out = c_char_p()
|
|
51
|
+
result_out = GopherAuthValidationResult()
|
|
52
|
+
|
|
53
|
+
err = fn(
|
|
54
|
+
auth_client.get_handle(),
|
|
55
|
+
oauth_client.get_handle(),
|
|
56
|
+
session_manager.get_handle(),
|
|
57
|
+
session_id.encode("utf-8"),
|
|
58
|
+
byref(token_out),
|
|
59
|
+
byref(result_out),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if err != GopherAuthError.SUCCESS:
|
|
63
|
+
return AutoRefreshResult(
|
|
64
|
+
valid=False,
|
|
65
|
+
error_code=err,
|
|
66
|
+
error_message=(
|
|
67
|
+
result_out.error_message.decode("utf-8")
|
|
68
|
+
if result_out.error_message
|
|
69
|
+
else f"Error code {err}"
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
new_token = None
|
|
74
|
+
if token_out.value:
|
|
75
|
+
new_token = token_out.value.decode("utf-8")
|
|
76
|
+
free = funcs.get("free_string")
|
|
77
|
+
if free:
|
|
78
|
+
free(token_out)
|
|
79
|
+
|
|
80
|
+
return AutoRefreshResult(
|
|
81
|
+
valid=result_out.valid,
|
|
82
|
+
new_access_token=new_token,
|
|
83
|
+
error_code=result_out.error_code,
|
|
84
|
+
error_message=(
|
|
85
|
+
result_out.error_message.decode("utf-8")
|
|
86
|
+
if result_out.error_message
|
|
87
|
+
else None
|
|
88
|
+
),
|
|
89
|
+
)
|