Skip to content

Commit 5598245

Browse files
committed
[client] add JWT management methods
1 parent 64eb232 commit 5598245

File tree

3 files changed

+256
-13
lines changed

3 files changed

+256
-13
lines changed

android_sms_gateway/client.py

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@ class BaseClient(abc.ABC):
1616
def __init__(
1717
self,
1818
login: t.Optional[str],
19-
password_or_token: str,
19+
password: str,
2020
*,
2121
base_url: str = DEFAULT_URL,
2222
encryptor: t.Optional[BaseEncryptor] = None,
2323
) -> None:
24-
if login and password_or_token:
25-
auth_header = f"Basic {base64.b64encode(f'{login}:{password_or_token}'.encode()).decode()}"
26-
elif password_or_token:
27-
auth_header = f"Bearer {password_or_token}"
24+
if login and password:
25+
auth_header = (
26+
f"Basic {base64.b64encode(f'{login}:{password}'.encode()).decode()}"
27+
)
28+
elif password:
29+
auth_header = f"Bearer {password}"
2830
else:
2931
raise ValueError("Either login and password or token must be provided")
3032

@@ -92,15 +94,15 @@ class APIClient(BaseClient):
9294
def __init__(
9395
self,
9496
login: t.Optional[str],
95-
password_or_token: str,
97+
password: str,
9698
*,
9799
base_url: str = DEFAULT_URL,
98100
encryptor: t.Optional[BaseEncryptor] = None,
99101
http: t.Optional[http.HttpClient] = None,
100102
) -> None:
101103
super().__init__(
102104
login=login,
103-
password_or_token=password_or_token,
105+
password=password,
104106
base_url=base_url,
105107
encryptor=encryptor,
106108
)
@@ -226,20 +228,55 @@ def health_check(self) -> dict:
226228

227229
return self.http.get(f"{self.base_url}/health", headers=self.headers)
228230

231+
def generate_token(
232+
self, token_request: domain.TokenRequest
233+
) -> domain.TokenResponse:
234+
"""
235+
Generates a new JWT token with specified scopes and TTL.
236+
237+
Args:
238+
token_request: The token request containing scopes and optional TTL.
239+
240+
Returns:
241+
The generated token response.
242+
"""
243+
if self.http is None:
244+
raise ValueError("HTTP client not initialized")
245+
246+
return domain.TokenResponse.from_dict(
247+
self.http.post(
248+
f"{self.base_url}/auth/token",
249+
payload=token_request.asdict(),
250+
headers=self.headers,
251+
)
252+
)
253+
254+
def revoke_token(self, jti: str) -> None:
255+
"""
256+
Revokes a JWT token with the specified JTI (token ID).
257+
258+
Args:
259+
jti: The JTI (token ID) of the token to revoke.
260+
"""
261+
if self.http is None:
262+
raise ValueError("HTTP client not initialized")
263+
264+
self.http.delete(f"{self.base_url}/auth/token/{jti}", headers=self.headers)
265+
229266

230267
class AsyncAPIClient(BaseClient):
231268
def __init__(
232269
self,
233270
login: t.Optional[str],
234-
password_or_token: str,
271+
password: str,
235272
*,
236273
base_url: str = DEFAULT_URL,
237274
encryptor: t.Optional[BaseEncryptor] = None,
238275
http_client: t.Optional[ahttp.AsyncHttpClient] = None,
239276
) -> None:
240277
super().__init__(
241278
login=login,
242-
password_or_token=password_or_token,
279+
password=password,
243280
base_url=base_url,
244281
encryptor=encryptor,
245282
)
@@ -258,7 +295,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
258295
if self.default_http is None:
259296
return
260297

261-
self.default_http.__aexit__(exc_type, exc_val, exc_tb)
298+
await self.default_http.__aexit__(exc_type, exc_val, exc_tb)
262299
self.http = self.default_http = None
263300

264301
async def send(self, message: domain.Message) -> domain.MessageState:
@@ -366,3 +403,40 @@ async def health_check(self) -> dict:
366403
raise ValueError("HTTP client not initialized")
367404

368405
return await self.http.get(f"{self.base_url}/health", headers=self.headers)
406+
407+
async def generate_token(
408+
self, token_request: domain.TokenRequest
409+
) -> domain.TokenResponse:
410+
"""
411+
Generates a new JWT token with specified scopes and TTL.
412+
413+
Args:
414+
token_request: The token request containing scopes and optional TTL.
415+
416+
Returns:
417+
The generated token response.
418+
"""
419+
if self.http is None:
420+
raise ValueError("HTTP client not initialized")
421+
422+
return domain.TokenResponse.from_dict(
423+
await self.http.post(
424+
f"{self.base_url}/auth/token",
425+
payload=token_request.asdict(),
426+
headers=self.headers,
427+
)
428+
)
429+
430+
async def revoke_token(self, jti: str) -> None:
431+
"""
432+
Revokes a JWT token with the specified JTI (token ID).
433+
434+
Args:
435+
jti: The JTI (token ID) of the token to revoke.
436+
"""
437+
if self.http is None:
438+
raise ValueError("HTTP client not initialized")
439+
440+
await self.http.delete(
441+
f"{self.base_url}/auth/token/{jti}", headers=self.headers
442+
)

android_sms_gateway/domain.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,57 @@ def from_dict(cls, payload: t.Dict[str, t.Any]) -> "ErrorResponse":
266266
code=payload["code"],
267267
message=payload["message"],
268268
)
269+
270+
271+
@dataclasses.dataclass(frozen=True)
272+
class TokenRequest:
273+
"""Represents a request to generate a new JWT token."""
274+
275+
scopes: t.List[str]
276+
"""List of scopes for the token."""
277+
ttl: t.Optional[int] = None
278+
"""Time to live for the token in seconds."""
279+
280+
def asdict(self) -> t.Dict[str, t.Any]:
281+
"""Returns a dictionary representation of the token request.
282+
283+
Returns:
284+
A dictionary containing the token request data.
285+
"""
286+
result: t.Dict[str, t.Any] = {
287+
"scopes": self.scopes,
288+
}
289+
if self.ttl is not None:
290+
result["ttl"] = self.ttl
291+
return result
292+
293+
294+
@dataclasses.dataclass(frozen=True)
295+
class TokenResponse:
296+
"""Represents a response when generating a new JWT token."""
297+
298+
access_token: str
299+
"""The JWT access token."""
300+
token_type: str
301+
"""The type of the token (e.g., 'Bearer')."""
302+
id: str
303+
"""The unique identifier of the token (jti)."""
304+
expires_at: str
305+
"""The expiration time of the token in ISO format."""
306+
307+
@classmethod
308+
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "TokenResponse":
309+
"""Creates a TokenResponse instance from a dictionary.
310+
311+
Args:
312+
payload: A dictionary containing the token response data.
313+
314+
Returns:
315+
A TokenResponse instance.
316+
"""
317+
return cls(
318+
access_token=payload["accessToken"],
319+
token_type=payload["tokenType"],
320+
id=payload["id"],
321+
expires_at=payload["expiresAt"],
322+
)

tests/test_jwt_auth.py

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
24
from android_sms_gateway.client import APIClient, AsyncAPIClient
35
from android_sms_gateway.constants import DEFAULT_URL
6+
from android_sms_gateway.domain import TokenRequest, TokenResponse
47
from android_sms_gateway.http import RequestsHttpClient
58

69

@@ -28,7 +31,7 @@ def test_jwt_auth_initialization():
2831
RequestsHttpClient() as h,
2932
APIClient(
3033
login=None,
31-
password_or_token="test_jwt_token",
34+
password="test_jwt_token",
3235
base_url=DEFAULT_URL,
3336
http=h,
3437
) as client,
@@ -58,7 +61,7 @@ def test_async_jwt_auth_initialization():
5861
"""Test that the async client can be initialized with JWT token."""
5962
client = AsyncAPIClient(
6063
login=None,
61-
password_or_token="test_jwt_token",
64+
password="test_jwt_token",
6265
base_url=DEFAULT_URL,
6366
)
6467
# Check that the Authorization header is set correctly
@@ -83,7 +86,7 @@ def test_missing_password_error():
8386
ValueError,
8487
match="Either login and password or token must be provided",
8588
):
86-
APIClient(login="test_login", password_or_token="")
89+
APIClient(login="test_login", password="")
8790

8891

8992
def test_async_missing_credentials_error():
@@ -93,3 +96,115 @@ def test_async_missing_credentials_error():
9396
match="Either login and password or token must be provided",
9497
):
9598
AsyncAPIClient(None, "")
99+
100+
101+
def test_generate_token():
102+
"""Test that the client can generate a new JWT token."""
103+
mock_http = MagicMock()
104+
mock_http.post.return_value = {
105+
"accessToken": "test_access_token",
106+
"tokenType": "Bearer",
107+
"id": "test_token_id",
108+
"expiresAt": "2023-12-31T23:59:59Z",
109+
}
110+
111+
token_request = TokenRequest(scopes=["sms:send", "sms:read"], ttl=3600)
112+
113+
with APIClient(
114+
login=None,
115+
password="initial_token",
116+
base_url=DEFAULT_URL,
117+
http=mock_http,
118+
) as client:
119+
response = client.generate_token(token_request)
120+
121+
# Verify the response
122+
assert isinstance(response, TokenResponse)
123+
assert response.access_token == "test_access_token"
124+
assert response.token_type == "Bearer"
125+
assert response.id == "test_token_id"
126+
assert response.expires_at == "2023-12-31T23:59:59Z"
127+
128+
# Verify the HTTP call
129+
mock_http.post.assert_called_once_with(
130+
f"{DEFAULT_URL}/auth/token",
131+
payload=token_request.asdict(),
132+
headers=client.headers,
133+
)
134+
135+
136+
def test_revoke_token():
137+
"""Test that the client can revoke a JWT token."""
138+
mock_http = MagicMock()
139+
mock_http.delete.return_value = None
140+
141+
with APIClient(
142+
login=None,
143+
password="initial_token",
144+
base_url=DEFAULT_URL,
145+
http=mock_http,
146+
) as client:
147+
client.revoke_token("test_token_id")
148+
149+
# Verify the HTTP call
150+
mock_http.delete.assert_called_once_with(
151+
f"{DEFAULT_URL}/auth/token/test_token_id",
152+
headers=client.headers,
153+
)
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_async_generate_token():
158+
"""Test that the async client can generate a new JWT token."""
159+
mock_http = AsyncMock()
160+
mock_http.post.return_value = {
161+
"accessToken": "test_access_token",
162+
"tokenType": "Bearer",
163+
"id": "test_token_id",
164+
"expiresAt": "2023-12-31T23:59:59Z",
165+
}
166+
167+
token_request = TokenRequest(scopes=["sms:send", "sms:read"], ttl=3600)
168+
169+
async with AsyncAPIClient(
170+
login=None,
171+
password="initial_token",
172+
base_url=DEFAULT_URL,
173+
http_client=mock_http,
174+
) as client:
175+
response = await client.generate_token(token_request)
176+
177+
# Verify the response
178+
assert isinstance(response, TokenResponse)
179+
assert response.access_token == "test_access_token"
180+
assert response.token_type == "Bearer"
181+
assert response.id == "test_token_id"
182+
assert response.expires_at == "2023-12-31T23:59:59Z"
183+
184+
# Verify the HTTP call
185+
mock_http.post.assert_called_once_with(
186+
f"{DEFAULT_URL}/auth/token",
187+
payload=token_request.asdict(),
188+
headers=client.headers,
189+
)
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_async_revoke_token():
194+
"""Test that the async client can revoke a JWT token."""
195+
mock_http = AsyncMock()
196+
mock_http.delete.return_value = None
197+
198+
async with AsyncAPIClient(
199+
login=None,
200+
password="initial_token",
201+
base_url=DEFAULT_URL,
202+
http_client=mock_http,
203+
) as client:
204+
await client.revoke_token("test_token_id")
205+
206+
# Verify the HTTP call
207+
mock_http.delete.assert_called_once_with(
208+
f"{DEFAULT_URL}/auth/token/test_token_id",
209+
headers=client.headers,
210+
)

0 commit comments

Comments
 (0)