dominusnode 1.0.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.
- dominusnode/__init__.py +151 -0
- dominusnode/admin.py +252 -0
- dominusnode/agent_wallet.py +240 -0
- dominusnode/auth.py +271 -0
- dominusnode/client.py +457 -0
- dominusnode/constants.py +18 -0
- dominusnode/errors.py +91 -0
- dominusnode/http_client.py +435 -0
- dominusnode/keys.py +88 -0
- dominusnode/plans.py +93 -0
- dominusnode/proxy.py +248 -0
- dominusnode/py.typed +0 -0
- dominusnode/sessions.py +55 -0
- dominusnode/slots.py +60 -0
- dominusnode/teams.py +339 -0
- dominusnode/token_manager.py +235 -0
- dominusnode/types.py +521 -0
- dominusnode/usage.py +230 -0
- dominusnode/wallet.py +189 -0
- dominusnode/wallet_auth.py +241 -0
- dominusnode/x402.py +88 -0
- dominusnode-1.0.0.dist-info/LICENSE +21 -0
- dominusnode-1.0.0.dist-info/METADATA +13 -0
- dominusnode-1.0.0.dist-info/RECORD +26 -0
- dominusnode-1.0.0.dist-info/WHEEL +5 -0
- dominusnode-1.0.0.dist-info/top_level.txt +1 -0
dominusnode/teams.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Teams resource -- team management with members, invites, wallet, and API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
9
|
+
from .wallet import _validate_amount_cents
|
|
10
|
+
from .types import (
|
|
11
|
+
Team,
|
|
12
|
+
TeamMember,
|
|
13
|
+
TeamInvite,
|
|
14
|
+
TeamTransaction,
|
|
15
|
+
TeamKey,
|
|
16
|
+
TeamKeyCreateResponse,
|
|
17
|
+
TeamDeleteResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_team(data: Dict[str, Any]) -> Team:
|
|
22
|
+
return Team(
|
|
23
|
+
id=data["id"],
|
|
24
|
+
name=data["name"],
|
|
25
|
+
owner_id=data["ownerId"],
|
|
26
|
+
max_members=data.get("maxMembers", 0),
|
|
27
|
+
status=data.get("status", "active"),
|
|
28
|
+
balance_cents=data.get("balanceCents", 0),
|
|
29
|
+
created_at=data.get("createdAt", ""),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_member(data: Dict[str, Any]) -> TeamMember:
|
|
34
|
+
return TeamMember(
|
|
35
|
+
id=data.get("id", ""),
|
|
36
|
+
team_id=data.get("teamId", ""),
|
|
37
|
+
user_id=data["userId"],
|
|
38
|
+
email=data["email"],
|
|
39
|
+
role=data["role"],
|
|
40
|
+
joined_at=data.get("joinedAt", ""),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_invite(data: Dict[str, Any]) -> TeamInvite:
|
|
45
|
+
return TeamInvite(
|
|
46
|
+
id=data["id"],
|
|
47
|
+
team_id=data["teamId"],
|
|
48
|
+
email=data["email"],
|
|
49
|
+
role=data["role"],
|
|
50
|
+
token=data.get("token", ""),
|
|
51
|
+
expires_at=data.get("expiresAt", ""),
|
|
52
|
+
created_at=data.get("createdAt", ""),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_transaction(data: Dict[str, Any]) -> TeamTransaction:
|
|
57
|
+
return TeamTransaction(
|
|
58
|
+
id=data["id"],
|
|
59
|
+
team_id=data["teamId"],
|
|
60
|
+
type=data["type"],
|
|
61
|
+
amount_cents=data["amountCents"],
|
|
62
|
+
description=data["description"],
|
|
63
|
+
created_at=data.get("createdAt", ""),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_key(data: Dict[str, Any]) -> TeamKey:
|
|
68
|
+
return TeamKey(
|
|
69
|
+
id=data["id"],
|
|
70
|
+
key_prefix=data["keyPrefix"],
|
|
71
|
+
label=data["label"],
|
|
72
|
+
created_at=data.get("createdAt", ""),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TeamsResource:
|
|
77
|
+
"""Synchronous team operations."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
80
|
+
self._http = http
|
|
81
|
+
|
|
82
|
+
def create(self, name: str, max_members: Optional[int] = None) -> Team:
|
|
83
|
+
"""Create a new team."""
|
|
84
|
+
body: Dict[str, Any] = {"name": name}
|
|
85
|
+
if max_members is not None:
|
|
86
|
+
body["maxMembers"] = max_members
|
|
87
|
+
data = self._http.post("/api/teams", json=body)
|
|
88
|
+
return _parse_team(data)
|
|
89
|
+
|
|
90
|
+
def list(self) -> List[Team]:
|
|
91
|
+
"""List all teams the user belongs to."""
|
|
92
|
+
data = self._http.get("/api/teams")
|
|
93
|
+
return [_parse_team(t) for t in data]
|
|
94
|
+
|
|
95
|
+
def get(self, team_id: str) -> Team:
|
|
96
|
+
"""Get a single team by ID."""
|
|
97
|
+
data = self._http.get(f"/api/teams/{quote(team_id, safe='')}")
|
|
98
|
+
return _parse_team(data)
|
|
99
|
+
|
|
100
|
+
def update(self, team_id: str, name: Optional[str] = None, max_members: Optional[int] = None) -> Team:
|
|
101
|
+
"""Update a team's settings."""
|
|
102
|
+
body: Dict[str, Any] = {}
|
|
103
|
+
if name is not None:
|
|
104
|
+
body["name"] = name
|
|
105
|
+
if max_members is not None:
|
|
106
|
+
body["maxMembers"] = max_members
|
|
107
|
+
data = self._http.patch(f"/api/teams/{quote(team_id, safe='')}", json=body)
|
|
108
|
+
return _parse_team(data)
|
|
109
|
+
|
|
110
|
+
def delete(self, team_id: str) -> TeamDeleteResponse:
|
|
111
|
+
"""Delete a team and refund remaining wallet balance."""
|
|
112
|
+
data = self._http.delete(f"/api/teams/{quote(team_id, safe='')}")
|
|
113
|
+
return TeamDeleteResponse(refunded_cents=data["refundedCents"])
|
|
114
|
+
|
|
115
|
+
def fund_wallet(self, team_id: str, amount_cents: int) -> TeamTransaction:
|
|
116
|
+
"""Fund the team wallet from the user's main wallet."""
|
|
117
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
118
|
+
data = self._http.post(
|
|
119
|
+
f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
|
|
120
|
+
json={"amountCents": amount_cents},
|
|
121
|
+
)
|
|
122
|
+
return _parse_transaction(data["transaction"])
|
|
123
|
+
|
|
124
|
+
def get_transactions(self, team_id: str, limit: int = 50, offset: int = 0) -> List[TeamTransaction]:
|
|
125
|
+
"""Get wallet transaction history for a team."""
|
|
126
|
+
data = self._http.get(
|
|
127
|
+
f"/api/teams/{quote(team_id, safe='')}/wallet/transactions?limit={limit}&offset={offset}"
|
|
128
|
+
)
|
|
129
|
+
return [_parse_transaction(tx) for tx in data["transactions"]]
|
|
130
|
+
|
|
131
|
+
def list_members(self, team_id: str) -> List[TeamMember]:
|
|
132
|
+
"""List all members of a team."""
|
|
133
|
+
data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/members")
|
|
134
|
+
return [_parse_member(m) for m in data["members"]]
|
|
135
|
+
|
|
136
|
+
def add_member(self, team_id: str, email: str, role: Optional[str] = None) -> TeamMember:
|
|
137
|
+
"""Add a member to a team."""
|
|
138
|
+
body: Dict[str, Any] = {"email": email}
|
|
139
|
+
if role is not None:
|
|
140
|
+
body["role"] = role
|
|
141
|
+
data = self._http.post(f"/api/teams/{quote(team_id, safe='')}/members", json=body)
|
|
142
|
+
return _parse_member(data)
|
|
143
|
+
|
|
144
|
+
def update_member_role(self, team_id: str, user_id: str, role: str) -> TeamMember:
|
|
145
|
+
"""Update a member's role within a team."""
|
|
146
|
+
data = self._http.patch(
|
|
147
|
+
f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
|
|
148
|
+
json={"role": role},
|
|
149
|
+
)
|
|
150
|
+
return _parse_member(data)
|
|
151
|
+
|
|
152
|
+
def remove_member(self, team_id: str, user_id: str) -> None:
|
|
153
|
+
"""Remove a member from a team."""
|
|
154
|
+
self._http.delete(
|
|
155
|
+
f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def create_invite(self, team_id: str, email: str, role: Optional[str] = None) -> TeamInvite:
|
|
159
|
+
"""Create an invite for a team."""
|
|
160
|
+
body: Dict[str, Any] = {"email": email}
|
|
161
|
+
if role is not None:
|
|
162
|
+
body["role"] = role
|
|
163
|
+
data = self._http.post(f"/api/teams/{quote(team_id, safe='')}/invites", json=body)
|
|
164
|
+
return _parse_invite(data)
|
|
165
|
+
|
|
166
|
+
def list_invites(self, team_id: str) -> List[TeamInvite]:
|
|
167
|
+
"""List pending invites for a team."""
|
|
168
|
+
data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/invites")
|
|
169
|
+
return [_parse_invite(i) for i in data["invites"]]
|
|
170
|
+
|
|
171
|
+
def cancel_invite(self, team_id: str, invite_id: str) -> None:
|
|
172
|
+
"""Cancel a pending invite."""
|
|
173
|
+
self._http.delete(
|
|
174
|
+
f"/api/teams/{quote(team_id, safe='')}/invites/{quote(invite_id, safe='')}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def accept_invite(self, token: str) -> TeamMember:
|
|
178
|
+
"""Accept a team invite using the invite token."""
|
|
179
|
+
data = self._http.post(
|
|
180
|
+
f"/api/teams/invites/{quote(token, safe='')}/accept", json={}
|
|
181
|
+
)
|
|
182
|
+
return _parse_member(data)
|
|
183
|
+
|
|
184
|
+
def create_key(self, team_id: str, label: str) -> TeamKeyCreateResponse:
|
|
185
|
+
"""Create a new API key for the team."""
|
|
186
|
+
data = self._http.post(
|
|
187
|
+
f"/api/teams/{quote(team_id, safe='')}/keys",
|
|
188
|
+
json={"label": label},
|
|
189
|
+
)
|
|
190
|
+
return TeamKeyCreateResponse(
|
|
191
|
+
id=data["id"],
|
|
192
|
+
key=data["key"],
|
|
193
|
+
key_prefix=data["keyPrefix"],
|
|
194
|
+
label=data["label"],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def list_keys(self, team_id: str) -> List[TeamKey]:
|
|
198
|
+
"""List API keys for a team."""
|
|
199
|
+
data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/keys")
|
|
200
|
+
return [_parse_key(k) for k in data["keys"]]
|
|
201
|
+
|
|
202
|
+
def revoke_key(self, team_id: str, key_id: str) -> None:
|
|
203
|
+
"""Revoke a team API key by its ID."""
|
|
204
|
+
self._http.delete(
|
|
205
|
+
f"/api/teams/{quote(team_id, safe='')}/keys/{quote(key_id, safe='')}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class AsyncTeamsResource:
|
|
210
|
+
"""Asynchronous team operations."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
213
|
+
self._http = http
|
|
214
|
+
|
|
215
|
+
async def create(self, name: str, max_members: Optional[int] = None) -> Team:
|
|
216
|
+
"""Create a new team."""
|
|
217
|
+
body: Dict[str, Any] = {"name": name}
|
|
218
|
+
if max_members is not None:
|
|
219
|
+
body["maxMembers"] = max_members
|
|
220
|
+
data = await self._http.post("/api/teams", json=body)
|
|
221
|
+
return _parse_team(data)
|
|
222
|
+
|
|
223
|
+
async def list(self) -> List[Team]:
|
|
224
|
+
"""List all teams the user belongs to."""
|
|
225
|
+
data = await self._http.get("/api/teams")
|
|
226
|
+
return [_parse_team(t) for t in data]
|
|
227
|
+
|
|
228
|
+
async def get(self, team_id: str) -> Team:
|
|
229
|
+
"""Get a single team by ID."""
|
|
230
|
+
data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}")
|
|
231
|
+
return _parse_team(data)
|
|
232
|
+
|
|
233
|
+
async def update(self, team_id: str, name: Optional[str] = None, max_members: Optional[int] = None) -> Team:
|
|
234
|
+
"""Update a team's settings."""
|
|
235
|
+
body: Dict[str, Any] = {}
|
|
236
|
+
if name is not None:
|
|
237
|
+
body["name"] = name
|
|
238
|
+
if max_members is not None:
|
|
239
|
+
body["maxMembers"] = max_members
|
|
240
|
+
data = await self._http.patch(f"/api/teams/{quote(team_id, safe='')}", json=body)
|
|
241
|
+
return _parse_team(data)
|
|
242
|
+
|
|
243
|
+
async def delete(self, team_id: str) -> TeamDeleteResponse:
|
|
244
|
+
"""Delete a team and refund remaining wallet balance."""
|
|
245
|
+
data = await self._http.delete(f"/api/teams/{quote(team_id, safe='')}")
|
|
246
|
+
return TeamDeleteResponse(refunded_cents=data["refundedCents"])
|
|
247
|
+
|
|
248
|
+
async def fund_wallet(self, team_id: str, amount_cents: int) -> TeamTransaction:
|
|
249
|
+
"""Fund the team wallet from the user's main wallet."""
|
|
250
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
251
|
+
data = await self._http.post(
|
|
252
|
+
f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
|
|
253
|
+
json={"amountCents": amount_cents},
|
|
254
|
+
)
|
|
255
|
+
return _parse_transaction(data["transaction"])
|
|
256
|
+
|
|
257
|
+
async def get_transactions(self, team_id: str, limit: int = 50, offset: int = 0) -> List[TeamTransaction]:
|
|
258
|
+
"""Get wallet transaction history for a team."""
|
|
259
|
+
data = await self._http.get(
|
|
260
|
+
f"/api/teams/{quote(team_id, safe='')}/wallet/transactions?limit={limit}&offset={offset}"
|
|
261
|
+
)
|
|
262
|
+
return [_parse_transaction(tx) for tx in data["transactions"]]
|
|
263
|
+
|
|
264
|
+
async def list_members(self, team_id: str) -> List[TeamMember]:
|
|
265
|
+
"""List all members of a team."""
|
|
266
|
+
data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/members")
|
|
267
|
+
return [_parse_member(m) for m in data["members"]]
|
|
268
|
+
|
|
269
|
+
async def add_member(self, team_id: str, email: str, role: Optional[str] = None) -> TeamMember:
|
|
270
|
+
"""Add a member to a team."""
|
|
271
|
+
body: Dict[str, Any] = {"email": email}
|
|
272
|
+
if role is not None:
|
|
273
|
+
body["role"] = role
|
|
274
|
+
data = await self._http.post(f"/api/teams/{quote(team_id, safe='')}/members", json=body)
|
|
275
|
+
return _parse_member(data)
|
|
276
|
+
|
|
277
|
+
async def update_member_role(self, team_id: str, user_id: str, role: str) -> TeamMember:
|
|
278
|
+
"""Update a member's role within a team."""
|
|
279
|
+
data = await self._http.patch(
|
|
280
|
+
f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
|
|
281
|
+
json={"role": role},
|
|
282
|
+
)
|
|
283
|
+
return _parse_member(data)
|
|
284
|
+
|
|
285
|
+
async def remove_member(self, team_id: str, user_id: str) -> None:
|
|
286
|
+
"""Remove a member from a team."""
|
|
287
|
+
await self._http.delete(
|
|
288
|
+
f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def create_invite(self, team_id: str, email: str, role: Optional[str] = None) -> TeamInvite:
|
|
292
|
+
"""Create an invite for a team."""
|
|
293
|
+
body: Dict[str, Any] = {"email": email}
|
|
294
|
+
if role is not None:
|
|
295
|
+
body["role"] = role
|
|
296
|
+
data = await self._http.post(f"/api/teams/{quote(team_id, safe='')}/invites", json=body)
|
|
297
|
+
return _parse_invite(data)
|
|
298
|
+
|
|
299
|
+
async def list_invites(self, team_id: str) -> List[TeamInvite]:
|
|
300
|
+
"""List pending invites for a team."""
|
|
301
|
+
data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/invites")
|
|
302
|
+
return [_parse_invite(i) for i in data["invites"]]
|
|
303
|
+
|
|
304
|
+
async def cancel_invite(self, team_id: str, invite_id: str) -> None:
|
|
305
|
+
"""Cancel a pending invite."""
|
|
306
|
+
await self._http.delete(
|
|
307
|
+
f"/api/teams/{quote(team_id, safe='')}/invites/{quote(invite_id, safe='')}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
async def accept_invite(self, token: str) -> TeamMember:
|
|
311
|
+
"""Accept a team invite using the invite token."""
|
|
312
|
+
data = await self._http.post(
|
|
313
|
+
f"/api/teams/invites/{quote(token, safe='')}/accept", json={}
|
|
314
|
+
)
|
|
315
|
+
return _parse_member(data)
|
|
316
|
+
|
|
317
|
+
async def create_key(self, team_id: str, label: str) -> TeamKeyCreateResponse:
|
|
318
|
+
"""Create a new API key for the team."""
|
|
319
|
+
data = await self._http.post(
|
|
320
|
+
f"/api/teams/{quote(team_id, safe='')}/keys",
|
|
321
|
+
json={"label": label},
|
|
322
|
+
)
|
|
323
|
+
return TeamKeyCreateResponse(
|
|
324
|
+
id=data["id"],
|
|
325
|
+
key=data["key"],
|
|
326
|
+
key_prefix=data["keyPrefix"],
|
|
327
|
+
label=data["label"],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def list_keys(self, team_id: str) -> List[TeamKey]:
|
|
331
|
+
"""List API keys for a team."""
|
|
332
|
+
data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/keys")
|
|
333
|
+
return [_parse_key(k) for k in data["keys"]]
|
|
334
|
+
|
|
335
|
+
async def revoke_key(self, team_id: str, key_id: str) -> None:
|
|
336
|
+
"""Revoke a team API key by its ID."""
|
|
337
|
+
await self._http.delete(
|
|
338
|
+
f"/api/teams/{quote(team_id, safe='')}/keys/{quote(key_id, safe='')}"
|
|
339
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""JWT token management with auto-refresh.
|
|
2
|
+
|
|
3
|
+
Tokens are stored in memory only -- never written to disk.
|
|
4
|
+
JWT expiry is decoded from the base64 payload without cryptographic
|
|
5
|
+
verification (the server validates the signature).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
16
|
+
|
|
17
|
+
from .constants import TOKEN_REFRESH_BUFFER_SECONDS
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from typing import Awaitable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TokenManager:
|
|
24
|
+
"""Sync token manager with threading.Lock for singleton refresh."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._access_token: Optional[str] = None
|
|
28
|
+
self._refresh_token: Optional[str] = None
|
|
29
|
+
self._refresh_lock = threading.Lock()
|
|
30
|
+
self._refresh_fn: Optional[Callable[[str], tuple[str, str]]] = None
|
|
31
|
+
|
|
32
|
+
def set_tokens(self, access_token: str, refresh_token: str) -> None:
|
|
33
|
+
"""Store a new token pair."""
|
|
34
|
+
self._access_token = access_token
|
|
35
|
+
self._refresh_token = refresh_token
|
|
36
|
+
|
|
37
|
+
def set_refresh_fn(self, fn: Callable[[str], tuple[str, str]]) -> None:
|
|
38
|
+
"""Register the sync function that performs token refresh.
|
|
39
|
+
|
|
40
|
+
The function receives the current refresh token and must return
|
|
41
|
+
a (new_access_token, new_refresh_token) tuple.
|
|
42
|
+
"""
|
|
43
|
+
self._refresh_fn = fn
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
"""Remove stored tokens."""
|
|
47
|
+
self._access_token = None
|
|
48
|
+
self._refresh_token = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def access_token(self) -> Optional[str]:
|
|
52
|
+
return self._access_token
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def refresh_token(self) -> Optional[str]:
|
|
56
|
+
return self._refresh_token
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def has_tokens(self) -> bool:
|
|
60
|
+
return self._access_token is not None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def has_refresh_token(self) -> bool:
|
|
64
|
+
return self._refresh_token is not None
|
|
65
|
+
|
|
66
|
+
def get_valid_token(self) -> Optional[str]:
|
|
67
|
+
"""Return a valid access token, refreshing if needed.
|
|
68
|
+
|
|
69
|
+
Returns None if no tokens are available or refresh fails.
|
|
70
|
+
"""
|
|
71
|
+
if self._access_token is None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
if not is_expired(self._access_token):
|
|
75
|
+
return self._access_token
|
|
76
|
+
|
|
77
|
+
# Token expired or about to expire -- try refresh
|
|
78
|
+
return self._do_refresh()
|
|
79
|
+
|
|
80
|
+
def force_refresh(self) -> Optional[str]:
|
|
81
|
+
"""Force a token refresh regardless of expiry (e.g., after 401).
|
|
82
|
+
|
|
83
|
+
Raises RuntimeError if refresh fails instead of returning None,
|
|
84
|
+
preventing "Bearer None" from being sent as an auth header.
|
|
85
|
+
"""
|
|
86
|
+
return self._do_refresh(force=True)
|
|
87
|
+
|
|
88
|
+
def _do_refresh(self, force: bool = False) -> Optional[str]:
|
|
89
|
+
"""Perform a singleton refresh under lock."""
|
|
90
|
+
if self._refresh_token is None or self._refresh_fn is None:
|
|
91
|
+
if force:
|
|
92
|
+
raise RuntimeError("No valid token and cannot refresh")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
with self._refresh_lock:
|
|
96
|
+
# Double-check: another thread may have refreshed while we waited
|
|
97
|
+
# Skip double-check on force_refresh — the whole point is to NOT reuse current token
|
|
98
|
+
if not force and self._access_token and not is_expired(self._access_token):
|
|
99
|
+
return self._access_token
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
new_access, new_refresh = self._refresh_fn(self._refresh_token)
|
|
103
|
+
self._access_token = new_access
|
|
104
|
+
self._refresh_token = new_refresh
|
|
105
|
+
return self._access_token
|
|
106
|
+
except Exception:
|
|
107
|
+
self.clear()
|
|
108
|
+
if force:
|
|
109
|
+
raise RuntimeError("Token refresh failed")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AsyncTokenManager:
|
|
114
|
+
"""Async token manager with asyncio.Lock for singleton refresh.
|
|
115
|
+
|
|
116
|
+
The lock is created lazily on first use to avoid binding to an event loop
|
|
117
|
+
at __init__ time, which would fail if the TokenManager is constructed
|
|
118
|
+
outside of an async context (e.g., in frameworks that manage their own
|
|
119
|
+
event loops like FastAPI/uvicorn or Django ASGI).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._access_token: Optional[str] = None
|
|
124
|
+
self._refresh_token: Optional[str] = None
|
|
125
|
+
self._refresh_lock: Optional[asyncio.Lock] = None
|
|
126
|
+
self._refresh_fn: Optional[Callable[[str], Awaitable[tuple[str, str]]]] = None
|
|
127
|
+
|
|
128
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
129
|
+
"""Lazily create the asyncio.Lock on first use within an async context."""
|
|
130
|
+
if self._refresh_lock is None:
|
|
131
|
+
self._refresh_lock = asyncio.Lock()
|
|
132
|
+
return self._refresh_lock
|
|
133
|
+
|
|
134
|
+
def set_tokens(self, access_token: str, refresh_token: str) -> None:
|
|
135
|
+
"""Store a new token pair."""
|
|
136
|
+
self._access_token = access_token
|
|
137
|
+
self._refresh_token = refresh_token
|
|
138
|
+
|
|
139
|
+
def set_refresh_fn(self, fn: Callable[[str], Awaitable[tuple[str, str]]]) -> None:
|
|
140
|
+
"""Register the async function that performs token refresh."""
|
|
141
|
+
self._refresh_fn = fn
|
|
142
|
+
|
|
143
|
+
def clear(self) -> None:
|
|
144
|
+
"""Remove stored tokens."""
|
|
145
|
+
self._access_token = None
|
|
146
|
+
self._refresh_token = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def access_token(self) -> Optional[str]:
|
|
150
|
+
return self._access_token
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def refresh_token(self) -> Optional[str]:
|
|
154
|
+
return self._refresh_token
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def has_tokens(self) -> bool:
|
|
158
|
+
return self._access_token is not None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def has_refresh_token(self) -> bool:
|
|
162
|
+
return self._refresh_token is not None
|
|
163
|
+
|
|
164
|
+
async def get_valid_token(self) -> Optional[str]:
|
|
165
|
+
"""Return a valid access token, refreshing if needed."""
|
|
166
|
+
if self._access_token is None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if not is_expired(self._access_token):
|
|
170
|
+
return self._access_token
|
|
171
|
+
|
|
172
|
+
return await self._do_refresh()
|
|
173
|
+
|
|
174
|
+
async def force_refresh(self) -> Optional[str]:
|
|
175
|
+
"""Force a token refresh regardless of expiry (e.g., after 401).
|
|
176
|
+
|
|
177
|
+
Raises RuntimeError if refresh fails instead of returning None.
|
|
178
|
+
"""
|
|
179
|
+
return await self._do_refresh(force=True)
|
|
180
|
+
|
|
181
|
+
async def _do_refresh(self, force: bool = False) -> Optional[str]:
|
|
182
|
+
"""Perform a singleton refresh under async lock."""
|
|
183
|
+
if self._refresh_token is None or self._refresh_fn is None:
|
|
184
|
+
if force:
|
|
185
|
+
raise RuntimeError("No valid token and cannot refresh")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
async with self._get_lock():
|
|
189
|
+
# Skip double-check on force_refresh
|
|
190
|
+
if not force and self._access_token and not is_expired(self._access_token):
|
|
191
|
+
return self._access_token
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
new_access, new_refresh = await self._refresh_fn(self._refresh_token)
|
|
195
|
+
self._access_token = new_access
|
|
196
|
+
self._refresh_token = new_refresh
|
|
197
|
+
return self._access_token
|
|
198
|
+
except Exception:
|
|
199
|
+
self.clear()
|
|
200
|
+
if force:
|
|
201
|
+
raise RuntimeError("Token refresh failed")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_expired(token: str) -> bool:
|
|
206
|
+
"""Check if a JWT is expired or will expire within the buffer window.
|
|
207
|
+
|
|
208
|
+
Decodes the base64 payload to read the ``exp`` claim. No signature
|
|
209
|
+
verification is performed -- that is the server's responsibility.
|
|
210
|
+
|
|
211
|
+
Returns True if the token is expired / about to expire, or if the
|
|
212
|
+
token cannot be decoded.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
parts = token.split(".")
|
|
216
|
+
if len(parts) != 3:
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
# Base64url decode the payload (second segment)
|
|
220
|
+
payload_b64 = parts[1]
|
|
221
|
+
# Add padding
|
|
222
|
+
padding = 4 - len(payload_b64) % 4
|
|
223
|
+
if padding != 4:
|
|
224
|
+
payload_b64 += "=" * padding
|
|
225
|
+
|
|
226
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
227
|
+
payload = json.loads(payload_bytes)
|
|
228
|
+
|
|
229
|
+
exp = payload.get("exp")
|
|
230
|
+
if exp is None:
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
return time.time() >= (exp - TOKEN_REFRESH_BUFFER_SECONDS)
|
|
234
|
+
except Exception:
|
|
235
|
+
return True
|