dfns-sdk 0.0.0.dev0__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.
- dfns_sdk/__init__.py +24 -0
- dfns_sdk/_internal/__init__.py +5 -0
- dfns_sdk/_internal/http_client.py +395 -0
- dfns_sdk/auth.py +137 -0
- dfns_sdk/base_auth_api.py +129 -0
- dfns_sdk/client.py +81 -0
- dfns_sdk/delegated_client.py +97 -0
- dfns_sdk/generated/__init__.py +47 -0
- dfns_sdk/generated/agreements/__init__.py +7 -0
- dfns_sdk/generated/agreements/client.py +55 -0
- dfns_sdk/generated/agreements/delegated_client.py +95 -0
- dfns_sdk/generated/agreements/types.py +20 -0
- dfns_sdk/generated/allocations/__init__.py +7 -0
- dfns_sdk/generated/allocations/client.py +118 -0
- dfns_sdk/generated/allocations/delegated_client.py +187 -0
- dfns_sdk/generated/allocations/types.py +69 -0
- dfns_sdk/generated/auth/__init__.py +7 -0
- dfns_sdk/generated/auth/client.py +1215 -0
- dfns_sdk/generated/auth/delegated_client.py +1825 -0
- dfns_sdk/generated/auth/types.py +702 -0
- dfns_sdk/generated/exchanges/__init__.py +7 -0
- dfns_sdk/generated/exchanges/client.py +209 -0
- dfns_sdk/generated/exchanges/delegated_client.py +341 -0
- dfns_sdk/generated/exchanges/types.py +96 -0
- dfns_sdk/generated/fee_sponsors/__init__.py +7 -0
- dfns_sdk/generated/fee_sponsors/client.py +161 -0
- dfns_sdk/generated/fee_sponsors/delegated_client.py +287 -0
- dfns_sdk/generated/fee_sponsors/types.py +89 -0
- dfns_sdk/generated/keys/__init__.py +7 -0
- dfns_sdk/generated/keys/client.py +310 -0
- dfns_sdk/generated/keys/delegated_client.py +518 -0
- dfns_sdk/generated/keys/types.py +229 -0
- dfns_sdk/generated/networks/__init__.py +7 -0
- dfns_sdk/generated/networks/client.py +176 -0
- dfns_sdk/generated/networks/delegated_client.py +274 -0
- dfns_sdk/generated/networks/types.py +77 -0
- dfns_sdk/generated/permissions/__init__.py +7 -0
- dfns_sdk/generated/permissions/client.py +185 -0
- dfns_sdk/generated/permissions/delegated_client.py +344 -0
- dfns_sdk/generated/permissions/types.py +107 -0
- dfns_sdk/generated/policies/__init__.py +7 -0
- dfns_sdk/generated/policies/client.py +187 -0
- dfns_sdk/generated/policies/delegated_client.py +311 -0
- dfns_sdk/generated/policies/types.py +70 -0
- dfns_sdk/generated/signers/__init__.py +7 -0
- dfns_sdk/generated/signers/client.py +45 -0
- dfns_sdk/generated/signers/delegated_client.py +56 -0
- dfns_sdk/generated/signers/types.py +13 -0
- dfns_sdk/generated/staking/__init__.py +7 -0
- dfns_sdk/generated/staking/client.py +142 -0
- dfns_sdk/generated/staking/delegated_client.py +211 -0
- dfns_sdk/generated/staking/types.py +64 -0
- dfns_sdk/generated/swaps/__init__.py +7 -0
- dfns_sdk/generated/swaps/client.py +118 -0
- dfns_sdk/generated/swaps/delegated_client.py +157 -0
- dfns_sdk/generated/swaps/types.py +99 -0
- dfns_sdk/generated/wallets/__init__.py +7 -0
- dfns_sdk/generated/wallets/client.py +805 -0
- dfns_sdk/generated/wallets/delegated_client.py +1231 -0
- dfns_sdk/generated/wallets/types.py +534 -0
- dfns_sdk/generated/webhooks/__init__.py +7 -0
- dfns_sdk/generated/webhooks/client.py +193 -0
- dfns_sdk/generated/webhooks/delegated_client.py +320 -0
- dfns_sdk/generated/webhooks/types.py +101 -0
- dfns_sdk/types.py +72 -0
- dfns_sdk-0.0.0.dev0.dist-info/METADATA +191 -0
- dfns_sdk-0.0.0.dev0.dist-info/RECORD +69 -0
- dfns_sdk-0.0.0.dev0.dist-info/WHEEL +4 -0
- dfns_sdk-0.0.0.dev0.dist-info/licenses/LICENSE +21 -0
dfns_sdk/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Dfns Python SDK - Auto-generated from OpenAPI specification."""
|
|
2
|
+
|
|
3
|
+
from .client import DfnsClient
|
|
4
|
+
from .delegated_client import DfnsDelegatedClient
|
|
5
|
+
from .types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError
|
|
6
|
+
from .auth import Signer, KeySigner
|
|
7
|
+
from .base_auth_api import (
|
|
8
|
+
BaseAuthApi,
|
|
9
|
+
UserActionChallengeResponse,
|
|
10
|
+
SignUserActionChallengeRequest,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DfnsClient",
|
|
15
|
+
"DfnsDelegatedClient",
|
|
16
|
+
"DfnsClientConfig",
|
|
17
|
+
"DfnsDelegatedClientConfig",
|
|
18
|
+
"DfnsError",
|
|
19
|
+
"Signer",
|
|
20
|
+
"KeySigner",
|
|
21
|
+
"BaseAuthApi",
|
|
22
|
+
"UserActionChallengeResponse",
|
|
23
|
+
"SignUserActionChallengeRequest",
|
|
24
|
+
]
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""HTTP client for making API requests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional, TypeVar, TYPE_CHECKING
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from dfns_sdk.types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from dfns_sdk.auth import Signer
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpClient:
|
|
19
|
+
"""HTTP client for Dfns API requests."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: "DfnsClientConfig | DfnsDelegatedClientConfig"):
|
|
22
|
+
self.config = config
|
|
23
|
+
self._client = httpx.Client(
|
|
24
|
+
base_url=config.base_url,
|
|
25
|
+
timeout=30.0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _build_headers(self, user_action_token: Optional[str] = None) -> dict[str, str]:
|
|
29
|
+
"""Build request headers."""
|
|
30
|
+
headers = {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"Authorization": f"Bearer {self.config.auth_token}",
|
|
33
|
+
**self.config.headers,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if user_action_token:
|
|
37
|
+
headers["X-DFNS-USERACTION"] = user_action_token
|
|
38
|
+
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
def _build_url(
|
|
42
|
+
self,
|
|
43
|
+
path: str,
|
|
44
|
+
path_params: Optional[dict[str, Any]] = None,
|
|
45
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Build the full URL with path and query parameters."""
|
|
48
|
+
url = path
|
|
49
|
+
|
|
50
|
+
if path_params:
|
|
51
|
+
for key, value in path_params.items():
|
|
52
|
+
url = url.replace(f"{{{key}}}", str(value))
|
|
53
|
+
|
|
54
|
+
if query_params:
|
|
55
|
+
filtered_params = {k: v for k, v in query_params.items() if v is not None}
|
|
56
|
+
if filtered_params:
|
|
57
|
+
url = f"{url}?{urlencode(filtered_params)}"
|
|
58
|
+
|
|
59
|
+
return url
|
|
60
|
+
|
|
61
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
62
|
+
"""Handle API response and raise errors if needed."""
|
|
63
|
+
if response.status_code >= 400:
|
|
64
|
+
try:
|
|
65
|
+
error_data = response.json()
|
|
66
|
+
raise DfnsError(
|
|
67
|
+
message=error_data.get("message", "Unknown error"),
|
|
68
|
+
status_code=response.status_code,
|
|
69
|
+
error_code=error_data.get("error"),
|
|
70
|
+
details=error_data.get("details"),
|
|
71
|
+
)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
raise DfnsError(
|
|
74
|
+
message=response.text or "Unknown error",
|
|
75
|
+
status_code=response.status_code,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if response.status_code == 204 or not response.content:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
return response.json()
|
|
82
|
+
|
|
83
|
+
def _get_user_action_token(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path: str,
|
|
87
|
+
body: Optional[Any] = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Get a user action token by creating and signing a challenge.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
method: HTTP method.
|
|
94
|
+
path: Request path.
|
|
95
|
+
body: Request body.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The user action token to include in the request header.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
DfnsError: If no signer is configured or signing fails.
|
|
102
|
+
"""
|
|
103
|
+
if not self.config.signer:
|
|
104
|
+
raise DfnsError(
|
|
105
|
+
message="Signer required for this operation. Configure a signer in DfnsClientConfig.",
|
|
106
|
+
status_code=None,
|
|
107
|
+
error_code="SIGNER_REQUIRED",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Step 1: Create user action challenge
|
|
111
|
+
challenge_body = {
|
|
112
|
+
"userActionPayload": json.dumps(body, separators=(",", ":")) if body else "",
|
|
113
|
+
"userActionHttpMethod": method,
|
|
114
|
+
"userActionHttpPath": path,
|
|
115
|
+
"userActionServerKind": "Api",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
challenge_response = self._client.request(
|
|
119
|
+
method="POST",
|
|
120
|
+
url="/auth/action/init",
|
|
121
|
+
headers=self._build_headers(),
|
|
122
|
+
json=challenge_body,
|
|
123
|
+
)
|
|
124
|
+
challenge = self._handle_response(challenge_response)
|
|
125
|
+
|
|
126
|
+
# Step 2: Sign the challenge
|
|
127
|
+
assertion = self.config.signer.sign(challenge)
|
|
128
|
+
|
|
129
|
+
# Step 3: Submit signed challenge to get user action token
|
|
130
|
+
signature_body = {
|
|
131
|
+
"challengeIdentifier": challenge["challengeIdentifier"],
|
|
132
|
+
"firstFactor": assertion,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
signature_response = self._client.request(
|
|
136
|
+
method="POST",
|
|
137
|
+
url="/auth/action",
|
|
138
|
+
headers=self._build_headers(),
|
|
139
|
+
json=signature_body,
|
|
140
|
+
)
|
|
141
|
+
result = self._handle_response(signature_response)
|
|
142
|
+
|
|
143
|
+
return result["userAction"]
|
|
144
|
+
|
|
145
|
+
def request(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
path_params: Optional[dict[str, Any]] = None,
|
|
150
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
151
|
+
body: Optional[Any] = None,
|
|
152
|
+
requires_signature: bool = False,
|
|
153
|
+
) -> Any:
|
|
154
|
+
"""Make an HTTP request to the API."""
|
|
155
|
+
url = self._build_url(path, path_params, query_params)
|
|
156
|
+
|
|
157
|
+
# Get user action token if required
|
|
158
|
+
user_action_token = None
|
|
159
|
+
if requires_signature:
|
|
160
|
+
# Use the path with params substituted for signing
|
|
161
|
+
signing_path = path
|
|
162
|
+
if path_params:
|
|
163
|
+
for key, value in path_params.items():
|
|
164
|
+
signing_path = signing_path.replace(f"{{{key}}}", str(value))
|
|
165
|
+
user_action_token = self._get_user_action_token(method, signing_path, body)
|
|
166
|
+
|
|
167
|
+
headers = self._build_headers(user_action_token)
|
|
168
|
+
|
|
169
|
+
response = self._client.request(
|
|
170
|
+
method=method,
|
|
171
|
+
url=url,
|
|
172
|
+
headers=headers,
|
|
173
|
+
json=body if body is not None else None,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return self._handle_response(response)
|
|
177
|
+
|
|
178
|
+
def request_with_user_action(
|
|
179
|
+
self,
|
|
180
|
+
method: str,
|
|
181
|
+
path: str,
|
|
182
|
+
path_params: Optional[dict[str, Any]] = None,
|
|
183
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
184
|
+
body: Optional[Any] = None,
|
|
185
|
+
user_action: str = "",
|
|
186
|
+
) -> Any:
|
|
187
|
+
"""
|
|
188
|
+
Make an HTTP request with a pre-signed user action token.
|
|
189
|
+
|
|
190
|
+
This method is used by delegated clients where signing is handled externally.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
method: HTTP method.
|
|
194
|
+
path: Request path.
|
|
195
|
+
path_params: Path parameters to substitute.
|
|
196
|
+
query_params: Query parameters.
|
|
197
|
+
body: Request body.
|
|
198
|
+
user_action: Pre-signed user action token.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The API response.
|
|
202
|
+
"""
|
|
203
|
+
url = self._build_url(path, path_params, query_params)
|
|
204
|
+
headers = self._build_headers(user_action if user_action else None)
|
|
205
|
+
|
|
206
|
+
response = self._client.request(
|
|
207
|
+
method=method,
|
|
208
|
+
url=url,
|
|
209
|
+
headers=headers,
|
|
210
|
+
json=body if body is not None else None,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return self._handle_response(response)
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
"""Close the HTTP client."""
|
|
217
|
+
self._client.close()
|
|
218
|
+
|
|
219
|
+
def __enter__(self) -> "HttpClient":
|
|
220
|
+
return self
|
|
221
|
+
|
|
222
|
+
def __exit__(self, *args: Any) -> None:
|
|
223
|
+
self.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AsyncHttpClient:
|
|
228
|
+
"""Async HTTP client for Dfns API requests."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, config: DfnsClientConfig):
|
|
231
|
+
self.config = config
|
|
232
|
+
self._client = httpx.AsyncClient(
|
|
233
|
+
base_url=config.base_url,
|
|
234
|
+
timeout=30.0,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _build_headers(self, user_action_token: Optional[str] = None) -> dict[str, str]:
|
|
238
|
+
"""Build request headers."""
|
|
239
|
+
headers = {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
"Authorization": f"Bearer {self.config.auth_token}",
|
|
242
|
+
**self.config.headers,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if user_action_token:
|
|
246
|
+
headers["X-DFNS-USERACTION"] = user_action_token
|
|
247
|
+
|
|
248
|
+
return headers
|
|
249
|
+
|
|
250
|
+
def _build_url(
|
|
251
|
+
self,
|
|
252
|
+
path: str,
|
|
253
|
+
path_params: Optional[dict[str, Any]] = None,
|
|
254
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Build the full URL with path and query parameters."""
|
|
257
|
+
url = path
|
|
258
|
+
|
|
259
|
+
if path_params:
|
|
260
|
+
for key, value in path_params.items():
|
|
261
|
+
url = url.replace(f"{{{key}}}", str(value))
|
|
262
|
+
|
|
263
|
+
if query_params:
|
|
264
|
+
filtered_params = {k: v for k, v in query_params.items() if v is not None}
|
|
265
|
+
if filtered_params:
|
|
266
|
+
url = f"{url}?{urlencode(filtered_params)}"
|
|
267
|
+
|
|
268
|
+
return url
|
|
269
|
+
|
|
270
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
271
|
+
"""Handle API response and raise errors if needed."""
|
|
272
|
+
if response.status_code >= 400:
|
|
273
|
+
try:
|
|
274
|
+
error_data = response.json()
|
|
275
|
+
raise DfnsError(
|
|
276
|
+
message=error_data.get("message", "Unknown error"),
|
|
277
|
+
status_code=response.status_code,
|
|
278
|
+
error_code=error_data.get("error"),
|
|
279
|
+
details=error_data.get("details"),
|
|
280
|
+
)
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
raise DfnsError(
|
|
283
|
+
message=response.text or "Unknown error",
|
|
284
|
+
status_code=response.status_code,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if response.status_code == 204 or not response.content:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
return response.json()
|
|
291
|
+
|
|
292
|
+
async def _get_user_action_token(
|
|
293
|
+
self,
|
|
294
|
+
method: str,
|
|
295
|
+
path: str,
|
|
296
|
+
body: Optional[Any] = None,
|
|
297
|
+
) -> str:
|
|
298
|
+
"""
|
|
299
|
+
Get a user action token by creating and signing a challenge.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
method: HTTP method.
|
|
303
|
+
path: Request path.
|
|
304
|
+
body: Request body.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The user action token to include in the request header.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
DfnsError: If no signer is configured or signing fails.
|
|
311
|
+
"""
|
|
312
|
+
if not self.config.signer:
|
|
313
|
+
raise DfnsError(
|
|
314
|
+
message="Signer required for this operation. Configure a signer in DfnsClientConfig.",
|
|
315
|
+
status_code=None,
|
|
316
|
+
error_code="SIGNER_REQUIRED",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Step 1: Create user action challenge
|
|
320
|
+
challenge_body = {
|
|
321
|
+
"userActionPayload": json.dumps(body, separators=(",", ":")) if body else "",
|
|
322
|
+
"userActionHttpMethod": method,
|
|
323
|
+
"userActionHttpPath": path,
|
|
324
|
+
"userActionServerKind": "Api",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
challenge_response = await self._client.request(
|
|
328
|
+
method="POST",
|
|
329
|
+
url="/auth/action/init",
|
|
330
|
+
headers=self._build_headers(),
|
|
331
|
+
json=challenge_body,
|
|
332
|
+
)
|
|
333
|
+
challenge = self._handle_response(challenge_response)
|
|
334
|
+
|
|
335
|
+
# Step 2: Sign the challenge
|
|
336
|
+
assertion = self.config.signer.sign(challenge)
|
|
337
|
+
|
|
338
|
+
# Step 3: Submit signed challenge to get user action token
|
|
339
|
+
signature_body = {
|
|
340
|
+
"challengeIdentifier": challenge["challengeIdentifier"],
|
|
341
|
+
"firstFactor": assertion,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
signature_response = await self._client.request(
|
|
345
|
+
method="POST",
|
|
346
|
+
url="/auth/action",
|
|
347
|
+
headers=self._build_headers(),
|
|
348
|
+
json=signature_body,
|
|
349
|
+
)
|
|
350
|
+
result = self._handle_response(signature_response)
|
|
351
|
+
|
|
352
|
+
return result["userAction"]
|
|
353
|
+
|
|
354
|
+
async def request(
|
|
355
|
+
self,
|
|
356
|
+
method: str,
|
|
357
|
+
path: str,
|
|
358
|
+
path_params: Optional[dict[str, Any]] = None,
|
|
359
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
360
|
+
body: Optional[Any] = None,
|
|
361
|
+
requires_signature: bool = False,
|
|
362
|
+
) -> Any:
|
|
363
|
+
"""Make an async HTTP request to the API."""
|
|
364
|
+
url = self._build_url(path, path_params, query_params)
|
|
365
|
+
|
|
366
|
+
# Get user action token if required
|
|
367
|
+
user_action_token = None
|
|
368
|
+
if requires_signature:
|
|
369
|
+
# Use the path with params substituted for signing
|
|
370
|
+
signing_path = path
|
|
371
|
+
if path_params:
|
|
372
|
+
for key, value in path_params.items():
|
|
373
|
+
signing_path = signing_path.replace(f"{{{key}}}", str(value))
|
|
374
|
+
user_action_token = await self._get_user_action_token(method, signing_path, body)
|
|
375
|
+
|
|
376
|
+
headers = self._build_headers(user_action_token)
|
|
377
|
+
|
|
378
|
+
response = await self._client.request(
|
|
379
|
+
method=method,
|
|
380
|
+
url=url,
|
|
381
|
+
headers=headers,
|
|
382
|
+
json=body if body is not None else None,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return self._handle_response(response)
|
|
386
|
+
|
|
387
|
+
async def close(self) -> None:
|
|
388
|
+
"""Close the HTTP client."""
|
|
389
|
+
await self._client.aclose()
|
|
390
|
+
|
|
391
|
+
async def __aenter__(self) -> "AsyncHttpClient":
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
395
|
+
await self.close()
|
dfns_sdk/auth.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Authentication utilities for the Dfns SDK."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Optional, Protocol, TypedDict
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserActionChallenge(TypedDict):
|
|
13
|
+
"""Challenge returned by the server for user action signing."""
|
|
14
|
+
|
|
15
|
+
challenge: str
|
|
16
|
+
challengeIdentifier: str
|
|
17
|
+
supportedCredentialKinds: list[dict[str, Any]]
|
|
18
|
+
allowCredentials: dict[str, Any]
|
|
19
|
+
rp: dict[str, str]
|
|
20
|
+
externalAuthenticationUrl: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CredentialAssertion(TypedDict):
|
|
24
|
+
"""Credential assertion for signing."""
|
|
25
|
+
|
|
26
|
+
kind: str
|
|
27
|
+
credentialAssertion: dict[str, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Signer(Protocol):
|
|
31
|
+
"""Protocol for signing user action challenges."""
|
|
32
|
+
|
|
33
|
+
def sign(self, challenge: UserActionChallenge) -> CredentialAssertion:
|
|
34
|
+
"""
|
|
35
|
+
Sign a user action challenge.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
challenge: The challenge from the server.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The credential assertion to submit to the server.
|
|
42
|
+
"""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def base64url_encode(data: bytes) -> str:
|
|
47
|
+
"""Encode bytes to base64url string without padding."""
|
|
48
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def base64url_decode(data: str) -> bytes:
|
|
52
|
+
"""Decode base64url string to bytes."""
|
|
53
|
+
padding = 4 - len(data) % 4
|
|
54
|
+
if padding != 4:
|
|
55
|
+
data += "=" * padding
|
|
56
|
+
return base64.urlsafe_b64decode(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KeySigner:
|
|
60
|
+
"""
|
|
61
|
+
Signer implementation using a private key credential.
|
|
62
|
+
|
|
63
|
+
Supports Ed25519, ECDSA (secp256k1, P-256), and RSA keys.
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
>>> from dfns_sdk.auth import KeySigner
|
|
67
|
+
>>> signer = KeySigner(
|
|
68
|
+
... credential_id="cr-xxx-xxx",
|
|
69
|
+
... private_key=open("private_key.pem").read(),
|
|
70
|
+
... app_origin="https://app.dfns.io",
|
|
71
|
+
... )
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
credential_id: str,
|
|
77
|
+
private_key: str,
|
|
78
|
+
app_origin: str = "https://app.dfns.io",
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Initialize the key signer.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
credential_id: The credential ID (cr-xxx-xxx format).
|
|
85
|
+
private_key: The private key in PEM format.
|
|
86
|
+
app_origin: The application origin for the client data.
|
|
87
|
+
"""
|
|
88
|
+
self.credential_id = credential_id
|
|
89
|
+
self.app_origin = app_origin
|
|
90
|
+
self._private_key = serialization.load_pem_private_key(
|
|
91
|
+
private_key.encode() if isinstance(private_key, str) else private_key,
|
|
92
|
+
password=None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def sign(self, challenge: UserActionChallenge) -> CredentialAssertion:
|
|
96
|
+
"""
|
|
97
|
+
Sign a user action challenge.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
challenge: The challenge from the server.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The credential assertion to submit to the server.
|
|
104
|
+
"""
|
|
105
|
+
client_data = {
|
|
106
|
+
"type": "key.get",
|
|
107
|
+
"challenge": challenge["challenge"],
|
|
108
|
+
"origin": self.app_origin,
|
|
109
|
+
"crossOrigin": False,
|
|
110
|
+
}
|
|
111
|
+
client_data_json = json.dumps(client_data, separators=(",", ":"))
|
|
112
|
+
client_data_bytes = client_data_json.encode("utf-8")
|
|
113
|
+
|
|
114
|
+
signature = self._sign_bytes(client_data_bytes)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"kind": "Key",
|
|
118
|
+
"credentialAssertion": {
|
|
119
|
+
"credId": self.credential_id,
|
|
120
|
+
"clientData": base64url_encode(client_data_bytes),
|
|
121
|
+
"signature": base64url_encode(signature),
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def _sign_bytes(self, data: bytes) -> bytes:
|
|
126
|
+
"""Sign bytes with the private key."""
|
|
127
|
+
if isinstance(self._private_key, ed25519.Ed25519PrivateKey):
|
|
128
|
+
return self._private_key.sign(data)
|
|
129
|
+
elif isinstance(self._private_key, ec.EllipticCurvePrivateKey):
|
|
130
|
+
return self._private_key.sign(data, ec.ECDSA(hashes.SHA256()))
|
|
131
|
+
else:
|
|
132
|
+
# RSA
|
|
133
|
+
return self._private_key.sign(
|
|
134
|
+
data,
|
|
135
|
+
padding.PKCS1v15(),
|
|
136
|
+
hashes.SHA256(),
|
|
137
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Base authentication API for delegated client operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, TypedDict, Optional
|
|
5
|
+
|
|
6
|
+
from ._internal import HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AllowCredential(TypedDict):
|
|
10
|
+
"""Allowed credential for signing."""
|
|
11
|
+
|
|
12
|
+
type: str
|
|
13
|
+
id: str
|
|
14
|
+
transports: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupportedCredentialKind(TypedDict):
|
|
18
|
+
"""Supported credential kind."""
|
|
19
|
+
|
|
20
|
+
kind: str
|
|
21
|
+
factor: str
|
|
22
|
+
requiresSecondFactor: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UserActionChallengeResponse(TypedDict):
|
|
26
|
+
"""Response from creating a user action challenge."""
|
|
27
|
+
|
|
28
|
+
challengeIdentifier: str
|
|
29
|
+
challenge: str
|
|
30
|
+
allowCredentials: list[AllowCredential]
|
|
31
|
+
supportedCredentialKinds: list[SupportedCredentialKind]
|
|
32
|
+
userVerification: str
|
|
33
|
+
rp: dict[str, str]
|
|
34
|
+
externalAuthenticationUrl: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SignUserActionChallengeRequest(TypedDict, total=False):
|
|
38
|
+
"""Request to complete a user action challenge."""
|
|
39
|
+
|
|
40
|
+
challengeIdentifier: str
|
|
41
|
+
firstFactor: dict[str, Any]
|
|
42
|
+
secondFactor: dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseAuthApi:
|
|
46
|
+
"""
|
|
47
|
+
Helper class for delegated client user action signing.
|
|
48
|
+
|
|
49
|
+
This class provides static methods for the two-phase user action signing flow:
|
|
50
|
+
1. create_user_action_challenge() - Creates a challenge for external signing
|
|
51
|
+
2. sign_user_action_challenge() - Completes signing with the signed challenge
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> # Step 1: Create challenge
|
|
55
|
+
>>> challenge = BaseAuthApi.create_user_action_challenge(
|
|
56
|
+
... http_client,
|
|
57
|
+
... user_action_http_method="POST",
|
|
58
|
+
... user_action_http_path="/wallets",
|
|
59
|
+
... user_action_payload='{"network":"EthereumSepolia"}',
|
|
60
|
+
... )
|
|
61
|
+
>>>
|
|
62
|
+
>>> # Step 2: Sign externally (your signing system)
|
|
63
|
+
>>> signed = your_external_signer.sign(challenge)
|
|
64
|
+
>>>
|
|
65
|
+
>>> # Step 3: Complete with signed challenge
|
|
66
|
+
>>> result = BaseAuthApi.sign_user_action_challenge(
|
|
67
|
+
... http_client,
|
|
68
|
+
... signed_challenge={
|
|
69
|
+
... "challengeIdentifier": challenge["challengeIdentifier"],
|
|
70
|
+
... "firstFactor": signed,
|
|
71
|
+
... },
|
|
72
|
+
... )
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def create_user_action_challenge(
|
|
77
|
+
http_client: HttpClient,
|
|
78
|
+
user_action_http_method: str,
|
|
79
|
+
user_action_http_path: str,
|
|
80
|
+
user_action_payload: str,
|
|
81
|
+
user_action_server_kind: str = "Api",
|
|
82
|
+
) -> UserActionChallengeResponse:
|
|
83
|
+
"""
|
|
84
|
+
Create a user action challenge for signing.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
http_client: The HTTP client to use.
|
|
88
|
+
user_action_http_method: The HTTP method of the action (GET, POST, etc.).
|
|
89
|
+
user_action_http_path: The path of the action endpoint.
|
|
90
|
+
user_action_payload: The JSON-serialized request body.
|
|
91
|
+
user_action_server_kind: The server kind (default: "Api").
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The challenge response containing the challenge to sign.
|
|
95
|
+
"""
|
|
96
|
+
return http_client.request(
|
|
97
|
+
method="POST",
|
|
98
|
+
path="/auth/action/init",
|
|
99
|
+
body={
|
|
100
|
+
"userActionHttpMethod": user_action_http_method,
|
|
101
|
+
"userActionHttpPath": user_action_http_path,
|
|
102
|
+
"userActionPayload": user_action_payload,
|
|
103
|
+
"userActionServerKind": user_action_server_kind,
|
|
104
|
+
},
|
|
105
|
+
requires_signature=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def sign_user_action_challenge(
|
|
110
|
+
http_client: HttpClient,
|
|
111
|
+
signed_challenge: SignUserActionChallengeRequest,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Complete user action signing with a signed challenge.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
http_client: The HTTP client to use.
|
|
118
|
+
signed_challenge: The signed challenge containing challengeIdentifier
|
|
119
|
+
and firstFactor (and optional secondFactor).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary containing the userAction token.
|
|
123
|
+
"""
|
|
124
|
+
return http_client.request(
|
|
125
|
+
method="POST",
|
|
126
|
+
path="/auth/action",
|
|
127
|
+
body=signed_challenge,
|
|
128
|
+
requires_signature=False,
|
|
129
|
+
)
|