cinetpay-python 0.1.0__py3-none-any.whl
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.
- cinetpay/__init__.py +175 -0
- cinetpay/api/__init__.py +17 -0
- cinetpay/api/async_balance.py +70 -0
- cinetpay/api/async_payment.py +118 -0
- cinetpay/api/async_transfer.py +118 -0
- cinetpay/api/balance.py +70 -0
- cinetpay/api/payment.py +118 -0
- cinetpay/api/transfer.py +118 -0
- cinetpay/async_client.py +290 -0
- cinetpay/auth/__init__.py +11 -0
- cinetpay/auth/async_authenticator.py +119 -0
- cinetpay/auth/authenticator.py +140 -0
- cinetpay/auth/token_store.py +72 -0
- cinetpay/client.py +256 -0
- cinetpay/constants/__init__.py +15 -0
- cinetpay/constants/channels.py +12 -0
- cinetpay/constants/currencies.py +14 -0
- cinetpay/constants/payment_methods.py +43 -0
- cinetpay/constants/statuses.py +44 -0
- cinetpay/errors/__init__.py +14 -0
- cinetpay/errors/api_error.py +45 -0
- cinetpay/errors/auth_error.py +12 -0
- cinetpay/errors/base.py +18 -0
- cinetpay/errors/network_errors.py +29 -0
- cinetpay/http/__init__.py +9 -0
- cinetpay/http/async_http_client.py +173 -0
- cinetpay/http/http_client.py +173 -0
- cinetpay/logger.py +70 -0
- cinetpay/py.typed +0 -0
- cinetpay/types/__init__.py +59 -0
- cinetpay/types/balance.py +35 -0
- cinetpay/types/config.py +139 -0
- cinetpay/types/payment.py +210 -0
- cinetpay/types/transfer.py +163 -0
- cinetpay/types/webhook.py +59 -0
- cinetpay/validation.py +163 -0
- cinetpay/webhooks/__init__.py +8 -0
- cinetpay/webhooks/notification.py +79 -0
- cinetpay_python-0.1.0.dist-info/METADATA +376 -0
- cinetpay_python-0.1.0.dist-info/RECORD +41 -0
- cinetpay_python-0.1.0.dist-info/WHEEL +4 -0
cinetpay/api/transfer.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Synchronous transfer API — create transfers and check status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from cinetpay.auth.authenticator import Authenticator
|
|
9
|
+
from cinetpay.errors.api_error import ApiError
|
|
10
|
+
from cinetpay.http.http_client import HttpClient
|
|
11
|
+
from cinetpay.types.transfer import (
|
|
12
|
+
TransferRequest,
|
|
13
|
+
TransferResponse,
|
|
14
|
+
TransferStatus,
|
|
15
|
+
serialize_transfer_request,
|
|
16
|
+
to_transfer_response,
|
|
17
|
+
to_transfer_status,
|
|
18
|
+
)
|
|
19
|
+
from cinetpay.validation import validate_transfer_request
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransferApi:
|
|
23
|
+
"""CinetPay money transfer API.
|
|
24
|
+
|
|
25
|
+
Accessible via ``client.transfer``.
|
|
26
|
+
|
|
27
|
+
Example::
|
|
28
|
+
|
|
29
|
+
transfer = client.transfer.create(request, "CI")
|
|
30
|
+
status = client.transfer.get_status(transfer.transaction_id, "CI")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
http_client: HttpClient,
|
|
36
|
+
authenticators: dict[str, Authenticator],
|
|
37
|
+
) -> None:
|
|
38
|
+
self._http_client = http_client
|
|
39
|
+
self._authenticators = authenticators
|
|
40
|
+
|
|
41
|
+
def create(
|
|
42
|
+
self,
|
|
43
|
+
request: TransferRequest,
|
|
44
|
+
country: str,
|
|
45
|
+
) -> TransferResponse:
|
|
46
|
+
"""Create a money transfer to a mobile money number.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
request: Transfer parameters (amount, currency, phone, operator, etc.).
|
|
50
|
+
country: ISO country code (e.g. ``"CI"``, ``"SN"``).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Response containing initial status and transaction identifiers.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ApiError: If the API returns an error (e.g. INSUFFICIENT_BALANCE).
|
|
57
|
+
ValidationError: If a request field is invalid.
|
|
58
|
+
TypeError: If the country is not configured.
|
|
59
|
+
"""
|
|
60
|
+
validate_transfer_request(request)
|
|
61
|
+
auth = self._resolve_authenticator(country)
|
|
62
|
+
body = serialize_transfer_request(request)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
token = auth.get_token()
|
|
66
|
+
raw: dict[str, Any] = self._http_client.post("/v1/transfer", body, token)
|
|
67
|
+
return to_transfer_response(raw)
|
|
68
|
+
except ApiError as exc:
|
|
69
|
+
if _is_token_expired(exc):
|
|
70
|
+
token = auth.force_refresh()
|
|
71
|
+
raw = self._http_client.post("/v1/transfer", body, token)
|
|
72
|
+
return to_transfer_response(raw)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
def get_status(
|
|
76
|
+
self,
|
|
77
|
+
transaction_id: str,
|
|
78
|
+
country: str,
|
|
79
|
+
) -> TransferStatus:
|
|
80
|
+
"""Retrieve the current status of a transfer.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
transaction_id: CinetPay transaction ID or ``merchant_transaction_id``.
|
|
84
|
+
country: ISO country code (e.g. ``"CI"``, ``"SN"``).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Transfer status with recipient information.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ApiError: If the transaction is not found (NOT_FOUND).
|
|
91
|
+
"""
|
|
92
|
+
auth = self._resolve_authenticator(country)
|
|
93
|
+
path = f"/v1/transfer/{quote(transaction_id, safe='')}"
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
token = auth.get_token()
|
|
97
|
+
raw: dict[str, Any] = self._http_client.get(path, token)
|
|
98
|
+
return to_transfer_status(raw)
|
|
99
|
+
except ApiError as exc:
|
|
100
|
+
if _is_token_expired(exc):
|
|
101
|
+
token = auth.force_refresh()
|
|
102
|
+
raw = self._http_client.get(path, token)
|
|
103
|
+
return to_transfer_status(raw)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
def _resolve_authenticator(self, country: str) -> Authenticator:
|
|
107
|
+
key = country.upper()
|
|
108
|
+
auth = self._authenticators.get(key)
|
|
109
|
+
if auth is None:
|
|
110
|
+
available = ", ".join(self._authenticators.keys())
|
|
111
|
+
raise TypeError(
|
|
112
|
+
f'No credentials configured for country "{key}". Available: {available}'
|
|
113
|
+
)
|
|
114
|
+
return auth
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_token_expired(error: ApiError) -> bool:
|
|
118
|
+
return error.api_code == 1003 or error.api_status == "EXPIRED_TOKEN"
|
cinetpay/async_client.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Asynchronous CinetPay client — async entry point of the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from cinetpay.api.async_balance import AsyncBalanceApi
|
|
8
|
+
from cinetpay.api.async_payment import AsyncPaymentApi
|
|
9
|
+
from cinetpay.api.async_transfer import AsyncTransferApi
|
|
10
|
+
from cinetpay.auth.async_authenticator import AsyncAuthenticator
|
|
11
|
+
from cinetpay.auth.token_store import MemoryTokenStore
|
|
12
|
+
from cinetpay.http.async_http_client import AsyncHttpClient
|
|
13
|
+
from cinetpay.logger import LoggerProtocol, NoopLogger, StandardLibLogger
|
|
14
|
+
from cinetpay.types.config import (
|
|
15
|
+
API_KEY_PREFIX_LIVE,
|
|
16
|
+
API_KEY_PREFIX_TEST,
|
|
17
|
+
AsyncTokenStoreProtocol,
|
|
18
|
+
ClientConfig,
|
|
19
|
+
CountryCredentials,
|
|
20
|
+
DEFAULT_BASE_URL,
|
|
21
|
+
DEFAULT_TIMEOUT,
|
|
22
|
+
DEFAULT_TOKEN_TTL,
|
|
23
|
+
PRODUCTION_BASE_URL,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Hostnames allowed for the base URL (SSRF protection)
|
|
27
|
+
_ALLOWED_HOSTS = frozenset({
|
|
28
|
+
"api.cinetpay.net", # Sandbox
|
|
29
|
+
"api.cinetpay.co", # Production
|
|
30
|
+
"localhost",
|
|
31
|
+
"127.0.0.1",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _AsyncMemoryTokenStore:
|
|
36
|
+
"""Async adapter around the sync :class:`MemoryTokenStore`."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._store = MemoryTokenStore()
|
|
40
|
+
|
|
41
|
+
async def get(self, key: str) -> str | None:
|
|
42
|
+
"""Retrieve a token from the in-memory cache.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
key: Cache key (e.g. ``cinetpay_token_ci``).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The token string, or ``None`` if absent or expired.
|
|
49
|
+
"""
|
|
50
|
+
return self._store.get(key)
|
|
51
|
+
|
|
52
|
+
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
53
|
+
"""Store a token with a time-to-live.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
key: Cache key.
|
|
57
|
+
value: JWT token string.
|
|
58
|
+
ttl_seconds: Time-to-live in seconds.
|
|
59
|
+
"""
|
|
60
|
+
self._store.set(key, value, ttl_seconds)
|
|
61
|
+
|
|
62
|
+
async def delete(self, key: str) -> None:
|
|
63
|
+
"""Remove a token from the in-memory cache.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
key: Cache key to delete.
|
|
67
|
+
"""
|
|
68
|
+
self._store.delete(key)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AsyncCinetPayClient:
|
|
72
|
+
"""Asynchronous CinetPay SDK client.
|
|
73
|
+
|
|
74
|
+
Equivalent of :class:`~cinetpay.client.CinetPayClient` for async contexts
|
|
75
|
+
(FastAPI, aiohttp, etc.).
|
|
76
|
+
|
|
77
|
+
Example::
|
|
78
|
+
|
|
79
|
+
from cinetpay import AsyncCinetPayClient, ClientConfig, CountryCredentials
|
|
80
|
+
|
|
81
|
+
async with AsyncCinetPayClient(ClientConfig(
|
|
82
|
+
credentials={
|
|
83
|
+
"CI": CountryCredentials(api_key="sk_test_...", api_password="..."),
|
|
84
|
+
},
|
|
85
|
+
)) as client:
|
|
86
|
+
balance = await client.balance.get("CI")
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
payment: Async web payment API (initialize, get_status).
|
|
90
|
+
transfer: Async money transfer API (create, get_status).
|
|
91
|
+
balance: Async merchant balance API (get).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
payment: AsyncPaymentApi
|
|
95
|
+
"""Async web payment API -- initialization and status check."""
|
|
96
|
+
|
|
97
|
+
transfer: AsyncTransferApi
|
|
98
|
+
"""Async money transfer API -- send and check status."""
|
|
99
|
+
|
|
100
|
+
balance: AsyncBalanceApi
|
|
101
|
+
"""Async merchant account balance API."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
104
|
+
credentials = config.credentials
|
|
105
|
+
if not credentials:
|
|
106
|
+
raise TypeError(
|
|
107
|
+
"At least one country credential must be provided in config.credentials"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
token_ttl = config.token_ttl if config.token_ttl is not None else DEFAULT_TOKEN_TTL
|
|
111
|
+
timeout = config.timeout if config.timeout is not None else DEFAULT_TIMEOUT
|
|
112
|
+
|
|
113
|
+
# For async, we need an async-compatible token store
|
|
114
|
+
async_token_store: AsyncTokenStoreProtocol
|
|
115
|
+
if config.token_store is not None and isinstance(config.token_store, AsyncTokenStoreProtocol):
|
|
116
|
+
async_token_store = config.token_store # type: ignore[assignment]
|
|
117
|
+
else:
|
|
118
|
+
async_token_store = _AsyncMemoryTokenStore()
|
|
119
|
+
|
|
120
|
+
# Resolve logger
|
|
121
|
+
logger: LoggerProtocol
|
|
122
|
+
if config.logger is not None:
|
|
123
|
+
logger = config.logger
|
|
124
|
+
elif config.debug:
|
|
125
|
+
logger = StandardLibLogger()
|
|
126
|
+
else:
|
|
127
|
+
logger = NoopLogger()
|
|
128
|
+
|
|
129
|
+
# Detect environment
|
|
130
|
+
entries = list(credentials.items())
|
|
131
|
+
detected_env = _detect_environment(entries, logger)
|
|
132
|
+
|
|
133
|
+
# Resolve base URL
|
|
134
|
+
if config.base_url is not None:
|
|
135
|
+
base_url = config.base_url
|
|
136
|
+
elif detected_env == "live":
|
|
137
|
+
base_url = PRODUCTION_BASE_URL
|
|
138
|
+
else:
|
|
139
|
+
base_url = DEFAULT_BASE_URL
|
|
140
|
+
|
|
141
|
+
# Validate coherence
|
|
142
|
+
_validate_key_url_coherence(detected_env, base_url, logger)
|
|
143
|
+
|
|
144
|
+
# Enforce HTTPS
|
|
145
|
+
parsed = urlparse(base_url)
|
|
146
|
+
if parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1"):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
"base_url must use HTTPS for security. "
|
|
149
|
+
"Use https:// or localhost for development."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# SSRF protection
|
|
153
|
+
if parsed.hostname and not any(
|
|
154
|
+
parsed.hostname == h or parsed.hostname.endswith(f".{h}")
|
|
155
|
+
for h in _ALLOWED_HOSTS
|
|
156
|
+
):
|
|
157
|
+
logger.warn(
|
|
158
|
+
f'base_url hostname "{parsed.hostname}" is not a known CinetPay domain. '
|
|
159
|
+
f"Expected: {', '.join(sorted(_ALLOWED_HOSTS))}. Proceeding anyway."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not parsed.scheme or not parsed.hostname:
|
|
163
|
+
raise TypeError(f'base_url "{base_url}" is not a valid URL.')
|
|
164
|
+
|
|
165
|
+
# Build async HTTP client and authenticators
|
|
166
|
+
http_client = AsyncHttpClient(base_url, timeout, logger)
|
|
167
|
+
|
|
168
|
+
self._authenticators: dict[str, AsyncAuthenticator] = {}
|
|
169
|
+
for country, creds in entries:
|
|
170
|
+
key = country.upper()
|
|
171
|
+
self._authenticators[key] = AsyncAuthenticator(
|
|
172
|
+
http_client, key, creds, token_ttl, async_token_store, logger,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.payment = AsyncPaymentApi(http_client, self._authenticators)
|
|
176
|
+
self.transfer = AsyncTransferApi(http_client, self._authenticators)
|
|
177
|
+
self.balance = AsyncBalanceApi(http_client, self._authenticators)
|
|
178
|
+
|
|
179
|
+
self._http_client = http_client
|
|
180
|
+
self._logger = logger
|
|
181
|
+
|
|
182
|
+
logger.debug("AsyncCinetPayClient initialized", {
|
|
183
|
+
"countries": self.countries(),
|
|
184
|
+
"base_url": base_url,
|
|
185
|
+
"token_ttl": token_ttl,
|
|
186
|
+
"timeout": timeout,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
def countries(self) -> list[str]:
|
|
190
|
+
"""Return the list of configured country codes."""
|
|
191
|
+
return list(self._authenticators.keys())
|
|
192
|
+
|
|
193
|
+
async def revoke_token(self, country: str) -> None:
|
|
194
|
+
"""Revoke the cached JWT token for a country.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
country: ISO country code (e.g. ``"CI"``).
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
TypeError: If the country is not configured.
|
|
201
|
+
"""
|
|
202
|
+
key = country.upper()
|
|
203
|
+
auth = self._authenticators.get(key)
|
|
204
|
+
if auth is None:
|
|
205
|
+
raise TypeError(
|
|
206
|
+
f'No credentials configured for country "{key}". '
|
|
207
|
+
f"Available: {', '.join(self.countries())}"
|
|
208
|
+
)
|
|
209
|
+
await auth.clear_cache()
|
|
210
|
+
|
|
211
|
+
async def revoke_all_tokens(self) -> None:
|
|
212
|
+
"""Revoke all cached JWT tokens for all configured countries."""
|
|
213
|
+
for auth in self._authenticators.values():
|
|
214
|
+
await auth.clear_cache()
|
|
215
|
+
|
|
216
|
+
async def close(self) -> None:
|
|
217
|
+
"""Close the underlying async HTTP client and release resources."""
|
|
218
|
+
await self._http_client.close()
|
|
219
|
+
|
|
220
|
+
async def __aenter__(self) -> AsyncCinetPayClient:
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
async def __aexit__(self, *args: object) -> None:
|
|
224
|
+
await self.close()
|
|
225
|
+
|
|
226
|
+
def __repr__(self) -> str:
|
|
227
|
+
"""Prevent credential leakage via repr()."""
|
|
228
|
+
return f"AsyncCinetPayClient(countries={self.countries()!r})"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Private helpers (shared logic with sync client)
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _detect_environment(
|
|
237
|
+
entries: list[tuple[str, CountryCredentials]],
|
|
238
|
+
logger: LoggerProtocol,
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Detect test/live/mixed/unknown from API key prefixes."""
|
|
241
|
+
envs: set[str] = set()
|
|
242
|
+
for country, creds in entries:
|
|
243
|
+
if creds.api_key.startswith(API_KEY_PREFIX_TEST):
|
|
244
|
+
envs.add("test")
|
|
245
|
+
elif creds.api_key.startswith(API_KEY_PREFIX_LIVE):
|
|
246
|
+
envs.add("live")
|
|
247
|
+
else:
|
|
248
|
+
logger.warn(
|
|
249
|
+
f'API key for country {country.upper()} does not start with '
|
|
250
|
+
f'"{API_KEY_PREFIX_TEST}" or "{API_KEY_PREFIX_LIVE}". '
|
|
251
|
+
f"Expected format: sk_test_... (sandbox) or sk_live_... (production)."
|
|
252
|
+
)
|
|
253
|
+
envs.add("unknown")
|
|
254
|
+
|
|
255
|
+
if "test" in envs and "live" in envs:
|
|
256
|
+
logger.error(
|
|
257
|
+
"MIXED ENVIRONMENTS: some credentials use sk_test_ (sandbox) and "
|
|
258
|
+
"others use sk_live_ (production). This will cause authentication "
|
|
259
|
+
"failures. Use the same environment for all countries."
|
|
260
|
+
)
|
|
261
|
+
return "mixed"
|
|
262
|
+
|
|
263
|
+
if "live" in envs:
|
|
264
|
+
return "live"
|
|
265
|
+
if "test" in envs:
|
|
266
|
+
return "test"
|
|
267
|
+
return "unknown"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _validate_key_url_coherence(
|
|
271
|
+
detected_env: str,
|
|
272
|
+
base_url: str,
|
|
273
|
+
logger: LoggerProtocol,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Warn if API key type and base URL are mismatched."""
|
|
276
|
+
is_sandbox_url = "cinetpay.net" in base_url
|
|
277
|
+
is_production_url = "cinetpay.co" in base_url
|
|
278
|
+
|
|
279
|
+
if detected_env == "live" and is_sandbox_url:
|
|
280
|
+
logger.error(
|
|
281
|
+
f"ENVIRONMENT MISMATCH: production keys (sk_live_) are used with "
|
|
282
|
+
f'sandbox URL ({base_url}). Use base_url="{PRODUCTION_BASE_URL}" '
|
|
283
|
+
f"for production, or remove base_url to auto-detect."
|
|
284
|
+
)
|
|
285
|
+
if detected_env == "test" and is_production_url:
|
|
286
|
+
logger.error(
|
|
287
|
+
f"ENVIRONMENT MISMATCH: sandbox keys (sk_test_) are used with "
|
|
288
|
+
f'production URL ({base_url}). Use base_url="{DEFAULT_BASE_URL}" '
|
|
289
|
+
f"for sandbox, or remove base_url to auto-detect."
|
|
290
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Authentication module — token store, sync and async authenticators."""
|
|
2
|
+
|
|
3
|
+
from cinetpay.auth.authenticator import Authenticator
|
|
4
|
+
from cinetpay.auth.async_authenticator import AsyncAuthenticator
|
|
5
|
+
from cinetpay.auth.token_store import MemoryTokenStore
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AsyncAuthenticator",
|
|
9
|
+
"Authenticator",
|
|
10
|
+
"MemoryTokenStore",
|
|
11
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Asynchronous JWT authenticator with asyncio.Lock stampede guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cinetpay.errors.auth_error import AuthenticationError
|
|
9
|
+
from cinetpay.http.async_http_client import AsyncHttpClient
|
|
10
|
+
from cinetpay.logger import LoggerProtocol
|
|
11
|
+
from cinetpay.types.config import AsyncTokenStoreProtocol, CountryCredentials
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncAuthenticator:
|
|
15
|
+
"""Async JWT authenticator for a single country.
|
|
16
|
+
|
|
17
|
+
Equivalent of :class:`~cinetpay.auth.authenticator.Authenticator` but uses
|
|
18
|
+
:class:`asyncio.Lock` for the stampede guard and ``await`` for all I/O.
|
|
19
|
+
|
|
20
|
+
.. note::
|
|
21
|
+
Internal — used by :class:`~cinetpay.async_client.AsyncCinetPayClient`.
|
|
22
|
+
Do not instantiate directly.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
http_client: AsyncHttpClient,
|
|
28
|
+
country: str,
|
|
29
|
+
credentials: CountryCredentials,
|
|
30
|
+
token_ttl: int,
|
|
31
|
+
token_store: AsyncTokenStoreProtocol,
|
|
32
|
+
logger: LoggerProtocol,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._http_client = http_client
|
|
35
|
+
self._country = country
|
|
36
|
+
self._token_ttl = token_ttl
|
|
37
|
+
self._token_store = token_store
|
|
38
|
+
self._logger = logger
|
|
39
|
+
self._cache_key = f"cinetpay_token_{country.lower()}"
|
|
40
|
+
|
|
41
|
+
# Name-mangled to prevent accidental leakage
|
|
42
|
+
self.__api_key = credentials.api_key
|
|
43
|
+
self.__api_password = credentials.api_password
|
|
44
|
+
|
|
45
|
+
# Stampede guard
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
|
|
48
|
+
async def get_token(self) -> str:
|
|
49
|
+
"""Return a valid JWT token (from cache or freshly obtained).
|
|
50
|
+
|
|
51
|
+
Uses an :class:`asyncio.Lock` to prevent token stampede.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
JWT Bearer token.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
AuthenticationError: If the API does not return a token.
|
|
58
|
+
"""
|
|
59
|
+
cached = await self._token_store.get(self._cache_key)
|
|
60
|
+
if cached is not None:
|
|
61
|
+
self._logger.debug(f"Token cache hit for {self._country}")
|
|
62
|
+
return cached
|
|
63
|
+
|
|
64
|
+
self._logger.debug(f"Token cache miss for {self._country}, authenticating...")
|
|
65
|
+
|
|
66
|
+
async with self._lock:
|
|
67
|
+
# Double-check after acquiring the lock
|
|
68
|
+
cached = await self._token_store.get(self._cache_key)
|
|
69
|
+
if cached is not None:
|
|
70
|
+
self._logger.debug(f"Token cache hit for {self._country} (after lock)")
|
|
71
|
+
return cached
|
|
72
|
+
return await self._authenticate()
|
|
73
|
+
|
|
74
|
+
async def force_refresh(self) -> str:
|
|
75
|
+
"""Force token renewal by clearing cache then re-authenticating.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
New JWT token.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
AuthenticationError: If the API does not return a token.
|
|
82
|
+
"""
|
|
83
|
+
self._logger.warn(f"Force refreshing token for {self._country} (expired or invalid)")
|
|
84
|
+
await self._token_store.delete(self._cache_key)
|
|
85
|
+
return await self._authenticate()
|
|
86
|
+
|
|
87
|
+
async def clear_cache(self) -> None:
|
|
88
|
+
"""Delete the cached token for this country."""
|
|
89
|
+
await self._token_store.delete(self._cache_key)
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
"""Prevent credential leakage in logs and stack traces."""
|
|
93
|
+
return f"AsyncAuthenticator(country={self._country!r}, cache_key={self._cache_key!r})"
|
|
94
|
+
|
|
95
|
+
async def _authenticate(self) -> str:
|
|
96
|
+
"""Call ``POST /v1/oauth/login`` and cache the returned token."""
|
|
97
|
+
try:
|
|
98
|
+
data: dict[str, Any] = await self._http_client.post(
|
|
99
|
+
"/v1/oauth/login",
|
|
100
|
+
{
|
|
101
|
+
"api_key": self.__api_key,
|
|
102
|
+
"api_password": self.__api_password,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
raise AuthenticationError(
|
|
107
|
+
f"Authentication failed for country {self._country}"
|
|
108
|
+
) from None
|
|
109
|
+
|
|
110
|
+
access_token = data.get("access_token")
|
|
111
|
+
if not access_token:
|
|
112
|
+
raise AuthenticationError(
|
|
113
|
+
f"Authentication failed for country {self._country}: "
|
|
114
|
+
"no access_token in response"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await self._token_store.set(self._cache_key, access_token, self._token_ttl)
|
|
118
|
+
self._logger.debug(f"Token cached for {self._country} (TTL: {self._token_ttl}s)")
|
|
119
|
+
return access_token
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Synchronous JWT authenticator with stampede guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cinetpay.errors.auth_error import AuthenticationError
|
|
9
|
+
from cinetpay.http.http_client import HttpClient
|
|
10
|
+
from cinetpay.logger import LoggerProtocol
|
|
11
|
+
from cinetpay.types.config import CountryCredentials, TokenStoreProtocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Authenticator:
|
|
15
|
+
"""Manages JWT authentication for a single country.
|
|
16
|
+
|
|
17
|
+
Each CinetPay country has its own credentials (``api_key`` / ``api_password``).
|
|
18
|
+
The Authenticator obtains a JWT via ``POST /v1/oauth/login``, caches it in the
|
|
19
|
+
:class:`~cinetpay.types.config.TokenStoreProtocol`, and auto-renews on expiry.
|
|
20
|
+
|
|
21
|
+
Credentials are stored in name-mangled attributes and never exposed via
|
|
22
|
+
``repr()``, ``str()``, or ``vars()``.
|
|
23
|
+
|
|
24
|
+
.. note::
|
|
25
|
+
Internal — used by :class:`~cinetpay.client.CinetPayClient`.
|
|
26
|
+
Do not instantiate directly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
http_client: HttpClient,
|
|
32
|
+
country: str,
|
|
33
|
+
credentials: CountryCredentials,
|
|
34
|
+
token_ttl: int,
|
|
35
|
+
token_store: TokenStoreProtocol,
|
|
36
|
+
logger: LoggerProtocol,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._http_client = http_client
|
|
39
|
+
self._country = country
|
|
40
|
+
self._token_ttl = token_ttl
|
|
41
|
+
self._token_store = token_store
|
|
42
|
+
self._logger = logger
|
|
43
|
+
self._cache_key = f"cinetpay_token_{country.lower()}"
|
|
44
|
+
|
|
45
|
+
# Name-mangled to prevent accidental leakage
|
|
46
|
+
self.__api_key = credentials.api_key
|
|
47
|
+
self.__api_password = credentials.api_password
|
|
48
|
+
|
|
49
|
+
# Stampede guard: only one thread authenticates at a time
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
def get_token(self) -> str:
|
|
53
|
+
"""Return a valid JWT token (from cache or freshly obtained).
|
|
54
|
+
|
|
55
|
+
Uses a :class:`threading.Lock` to prevent token stampede: if multiple
|
|
56
|
+
threads call this concurrently with an empty cache, only one HTTP call
|
|
57
|
+
is made.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
JWT Bearer token.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
AuthenticationError: If the API does not return a token.
|
|
64
|
+
"""
|
|
65
|
+
cached = self._token_store.get(self._cache_key)
|
|
66
|
+
if cached is not None:
|
|
67
|
+
self._logger.debug(f"Token cache hit for {self._country}")
|
|
68
|
+
return cached
|
|
69
|
+
|
|
70
|
+
self._logger.debug(f"Token cache miss for {self._country}, authenticating...")
|
|
71
|
+
|
|
72
|
+
with self._lock:
|
|
73
|
+
# Double-check after acquiring the lock (another thread may have filled the cache)
|
|
74
|
+
cached = self._token_store.get(self._cache_key)
|
|
75
|
+
if cached is not None:
|
|
76
|
+
self._logger.debug(f"Token cache hit for {self._country} (after lock)")
|
|
77
|
+
return cached
|
|
78
|
+
return self._authenticate()
|
|
79
|
+
|
|
80
|
+
def force_refresh(self) -> str:
|
|
81
|
+
"""Force token renewal by clearing the cache then re-authenticating.
|
|
82
|
+
|
|
83
|
+
Called automatically by API classes when the API returns
|
|
84
|
+
``EXPIRED_TOKEN`` (code 1003).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
New JWT token.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
AuthenticationError: If the API does not return a token.
|
|
91
|
+
"""
|
|
92
|
+
self._logger.warn(f"Force refreshing token for {self._country} (expired or invalid)")
|
|
93
|
+
self._token_store.delete(self._cache_key)
|
|
94
|
+
return self._authenticate()
|
|
95
|
+
|
|
96
|
+
def clear_cache(self) -> None:
|
|
97
|
+
"""Delete the cached token for this country.
|
|
98
|
+
|
|
99
|
+
Useful to force re-authentication on the next API call.
|
|
100
|
+
"""
|
|
101
|
+
self._token_store.delete(self._cache_key)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
"""Prevent credential leakage in logs and stack traces."""
|
|
105
|
+
return f"Authenticator(country={self._country!r}, cache_key={self._cache_key!r})"
|
|
106
|
+
|
|
107
|
+
def _authenticate(self) -> str:
|
|
108
|
+
"""Call ``POST /v1/oauth/login`` and cache the returned token.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
JWT token.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
AuthenticationError: If the response is missing ``access_token``
|
|
115
|
+
or the HTTP call fails.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
data: dict[str, Any] = self._http_client.post(
|
|
119
|
+
"/v1/oauth/login",
|
|
120
|
+
{
|
|
121
|
+
"api_key": self.__api_key,
|
|
122
|
+
"api_password": self.__api_password,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
# Sanitize to prevent credential leakage in stack traces
|
|
127
|
+
raise AuthenticationError(
|
|
128
|
+
f"Authentication failed for country {self._country}"
|
|
129
|
+
) from None
|
|
130
|
+
|
|
131
|
+
access_token = data.get("access_token")
|
|
132
|
+
if not access_token:
|
|
133
|
+
raise AuthenticationError(
|
|
134
|
+
f"Authentication failed for country {self._country}: "
|
|
135
|
+
"no access_token in response"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
self._token_store.set(self._cache_key, access_token, self._token_ttl)
|
|
139
|
+
self._logger.debug(f"Token cached for {self._country} (TTL: {self._token_ttl}s)")
|
|
140
|
+
return access_token
|