silhouette-python-sdk 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.
silhouette/api/auth.py ADDED
@@ -0,0 +1,282 @@
1
+ """Authentication configuration and utilities for Silhouette API client."""
2
+
3
+ import getpass
4
+ import json
5
+ import random
6
+ import secrets
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, cast
13
+
14
+ import eth_account
15
+ from eth_account.messages import encode_defunct
16
+ from eth_account.signers.local import LocalAccount
17
+ from siwe import SiweMessage
18
+
19
+ if TYPE_CHECKING:
20
+ from silhouette.api.client import SilhouetteApiClient
21
+
22
+
23
+ @dataclass
24
+ class AuthConfig:
25
+ """Configuration for automatic authentication."""
26
+
27
+ auto_auth: bool = True
28
+ """Enable automatic authentication when API calls require it."""
29
+
30
+ max_login_retries: int = 3
31
+ """Maximum number of login attempts before giving up."""
32
+
33
+ login_retry_base_delay: float = 1.0
34
+ """Base delay in seconds between login retries (doubled each attempt)."""
35
+
36
+ login_retry_max_delay: float = 10.0
37
+ """Maximum delay in seconds between login retries (caps exponential backoff)."""
38
+
39
+ chain_id: int = 1
40
+ """Chain ID for SIWE message signing (1 for mainnet, 421614 for Arbitrum Sepolia testnet)."""
41
+
42
+
43
+ class AuthManager:
44
+ """Manages authentication state and automatic login for the API client."""
45
+
46
+ # Token TTL - assume 1 hour until server provides expiresAt
47
+ TOKEN_TTL_SECONDS = 3600
48
+
49
+ def __init__(
50
+ self,
51
+ client: "SilhouetteApiClient",
52
+ config: AuthConfig,
53
+ private_key: str | None = None,
54
+ keystore_path: str | None = None,
55
+ ):
56
+ """
57
+ Initialize the authentication manager.
58
+
59
+ Args:
60
+ client: The SilhouetteApiClient instance
61
+ config: Authentication configuration
62
+ private_key: Private key for auto-authentication (hex string with or without 0x prefix)
63
+ keystore_path: Path to encrypted keystore file (alternative to private_key)
64
+
65
+ Raises:
66
+ ValueError: If both private_key and keystore_path are provided
67
+ """
68
+ self.client = client
69
+ self.config = config
70
+ self._jwt_token: str | None = None
71
+ self._token_expires_at: float | None = None
72
+ self._wallet: LocalAccount | None = None
73
+ self._login_lock = threading.Lock()
74
+
75
+ # Load wallet if auto_auth is enabled
76
+ if self.config.auto_auth:
77
+ if private_key and keystore_path:
78
+ raise ValueError("Specify either private_key or keystore_path, not both")
79
+ if private_key:
80
+ self._wallet = eth_account.Account.from_key(private_key)
81
+ elif keystore_path:
82
+ self._wallet = self._load_keystore(keystore_path)
83
+
84
+ def _load_keystore(self, keystore_path: str) -> LocalAccount:
85
+ """
86
+ Load wallet from encrypted keystore file.
87
+
88
+ Args:
89
+ keystore_path: Path to keystore JSON file
90
+
91
+ Returns:
92
+ Decrypted LocalAccount
93
+
94
+ Raises:
95
+ FileNotFoundError: If keystore file doesn't exist
96
+ ValueError: If keystore path is not a file
97
+ """
98
+ path = Path(keystore_path).expanduser()
99
+ if not path.is_absolute():
100
+ # Make relative paths relative to the caller's directory
101
+ path = Path.cwd() / path
102
+
103
+ if not path.exists():
104
+ raise FileNotFoundError(f"Keystore file not found: {path}")
105
+ if not path.is_file():
106
+ raise ValueError(f"Keystore path is not a file: {path}")
107
+
108
+ with path.open() as f:
109
+ keystore = json.load(f)
110
+
111
+ password = getpass.getpass("Enter keystore password: ")
112
+ secret_key = eth_account.Account.decrypt(keystore, password)
113
+ return cast(LocalAccount, eth_account.Account.from_key(secret_key))
114
+
115
+ @property
116
+ def wallet(self) -> LocalAccount | None:
117
+ """Get the wallet used for authentication."""
118
+ return self._wallet
119
+
120
+ def get_token(self) -> str | None:
121
+ """Get the current JWT token."""
122
+ return self._jwt_token
123
+
124
+ def set_token(self, token: str, expires_at: float | None = None) -> None:
125
+ """
126
+ Set the JWT token and expiry time.
127
+
128
+ Args:
129
+ token: JWT token string
130
+ expires_at: Unix timestamp when token expires (defaults to now + TOKEN_TTL_SECONDS)
131
+ """
132
+ self._jwt_token = token
133
+ if expires_at is None:
134
+ expires_at = time.time() + self.TOKEN_TTL_SECONDS
135
+ self._token_expires_at = expires_at
136
+
137
+ def ensure_authenticated(self) -> None:
138
+ """
139
+ Ensure the client is authenticated, performing auto-login if necessary.
140
+
141
+ Raises:
142
+ ValueError: If auto_auth is disabled and no token is present
143
+ RuntimeError: If login fails after max retries
144
+ """
145
+
146
+ # Currently the only thing that unsets the token is handle_auth_error after a 401
147
+ # We can improve this later to proactively refresh before expiry if needed
148
+ if self._jwt_token is not None:
149
+ return # Already authenticated
150
+
151
+ if not self.config.auto_auth:
152
+ raise ValueError("Not authenticated and auto_auth is disabled. Call login() explicitly.")
153
+
154
+ if self._wallet is None:
155
+ raise ValueError("Cannot auto-authenticate without private_key or keystore_path")
156
+
157
+ # Use lock to prevent concurrent login attempts
158
+ with self._login_lock:
159
+ # Double-checked locking: another thread may have logged in while we waited for the lock
160
+ # This prevents redundant login attempts in multi-threaded scenarios
161
+ if self._jwt_token is not None:
162
+ return # type: ignore[unreachable] # Reachable when multiple threads call this concurrently
163
+
164
+ self._perform_login_with_retry()
165
+
166
+ def _perform_login_with_retry(self) -> None:
167
+ """
168
+ Perform login with exponential backoff and jitter.
169
+
170
+ Raises:
171
+ RuntimeError: If all retry attempts fail
172
+ """
173
+ last_error = None
174
+
175
+ for attempt in range(self.config.max_login_retries):
176
+ try:
177
+ self._perform_login()
178
+ return # Success
179
+ except Exception as e:
180
+ last_error = e
181
+ if attempt < self.config.max_login_retries - 1:
182
+ # Calculate delay with exponential backoff and jitter
183
+ delay = min(
184
+ self.config.login_retry_base_delay * (2**attempt),
185
+ self.config.login_retry_max_delay,
186
+ )
187
+ # 10% jitter
188
+ jitter = random.uniform(0, delay * 0.1) # noqa: S311
189
+
190
+ time.sleep(delay + jitter)
191
+
192
+ raise RuntimeError(f"Login failed after {self.config.max_login_retries} attempts: {last_error}") from last_error
193
+
194
+ def _extract_domain_from_url(self, url: str) -> str:
195
+ """
196
+ Extract domain (host:port) from a URL for SIWE message.
197
+
198
+ Args:
199
+ url: Full URL like "https://api.example.com:8081/path"
200
+
201
+ Returns:
202
+ Domain string like "api.example.com:8081" or "localhost:8081"
203
+ """
204
+ from urllib.parse import urlparse
205
+
206
+ parsed = urlparse(url)
207
+ # netloc includes host and port (e.g., "localhost:8081" or "api.example.com")
208
+ domain = parsed.netloc
209
+ if not domain:
210
+ # Fallback if URL parsing fails
211
+ domain = "localhost"
212
+ return domain
213
+
214
+ def _perform_login(self) -> None:
215
+ """
216
+ Perform the SIWE login flow.
217
+
218
+ Raises:
219
+ Various exceptions from SIWE message creation/signing or API calls
220
+ """
221
+ if self._wallet is None:
222
+ raise ValueError("No wallet configured for login")
223
+
224
+ # Extract domain from base_url for SIWE message
225
+ domain = self._extract_domain_from_url(self.client.base_url)
226
+
227
+ # Generate SIWE message
228
+ message = SiweMessage(
229
+ domain=domain,
230
+ address=self._wallet.address,
231
+ statement="Sign in with Ethereum to the app.",
232
+ uri=f"{self.client.base_url}/login",
233
+ version="1",
234
+ chain_id=self.config.chain_id,
235
+ nonce=secrets.token_hex(12),
236
+ issued_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
237
+ )
238
+ prepared_message_str = message.prepare_message()
239
+
240
+ # Sign the message
241
+ message_to_sign = encode_defunct(text=prepared_message_str)
242
+ signed_message = self._wallet.sign_message(message_to_sign)
243
+
244
+ # Verify signature client-side
245
+ message.verify(signed_message.signature)
246
+
247
+ # Call login API
248
+ signature_hex = "0x" + signed_message.signature.hex()
249
+ token = self.client._raw_login(prepared_message_str, signature_hex)
250
+ self.set_token(token)
251
+
252
+ def handle_auth_error(self, response_status: int) -> bool:
253
+ """
254
+ Handle authentication errors by attempting to re-login.
255
+
256
+ Args:
257
+ response_status: HTTP status code from failed request
258
+
259
+ Returns:
260
+ True if login was attempted and should retry the request, False otherwise
261
+ """
262
+ if response_status != 401:
263
+ return False
264
+
265
+ if not self.config.auto_auth:
266
+ return False
267
+
268
+ if self._wallet is None:
269
+ return False
270
+
271
+ # Clear expired token and re-login
272
+ with self._login_lock:
273
+ # Clear the expired token
274
+ self._jwt_token = None
275
+ self._token_expires_at = None
276
+
277
+ # Attempt login
278
+ try:
279
+ self._perform_login_with_retry()
280
+ return True # Retry the original request
281
+ except Exception:
282
+ return False # Login failed, don't retry