authforge-sdk 1.0.2__tar.gz → 1.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -94,7 +94,7 @@ client = AuthForgeClient(
94
94
 
95
95
  ## Billing
96
96
 
97
- - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
97
+ - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
98
98
  - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
99
99
 
100
100
  A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
@@ -104,6 +104,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
104
104
  | Method | Returns | Description |
105
105
  |---|---|---|
106
106
  | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
107
+ | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
107
108
  | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
108
109
  | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
109
110
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
@@ -121,6 +122,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
121
122
 
122
123
  If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
123
124
 
125
+ **`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
126
+
124
127
  Recognized server errors:
125
128
  `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
126
129
 
@@ -67,7 +67,7 @@ client = AuthForgeClient(
67
67
 
68
68
  ## Billing
69
69
 
70
- - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
70
+ - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
71
71
  - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
72
72
 
73
73
  A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
@@ -77,6 +77,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
77
77
  | Method | Returns | Description |
78
78
  |---|---|---|
79
79
  | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
80
+ | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
80
81
  | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
81
82
  | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
82
83
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
@@ -94,6 +95,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
94
95
 
95
96
  If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
96
97
 
98
+ **`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
99
+
97
100
  Recognized server errors:
98
101
  `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
99
102
 
@@ -11,14 +11,36 @@ import time
11
11
  import urllib.error
12
12
  import urllib.request
13
13
  import uuid
14
- from typing import Any, Callable, Dict, Optional
14
+ from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, Sequence, TypedDict, Union
15
15
  from cryptography.exceptions import InvalidSignature
16
16
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
17
17
 
18
18
 
19
+ PublicKeyArg = Union[str, Sequence[str]]
20
+
21
+
19
22
  DEFAULT_API_BASE_URL = "https://auth.authforge.cc"
20
23
  RATE_LIMIT_RETRY_DELAYS = (2, 5)
21
24
  NETWORK_RETRY_DELAY = 2
25
+ class ValidateLicenseSuccess(TypedDict):
26
+ valid: Literal[True]
27
+ session_token: str
28
+ expires_in: int
29
+ session_data: Dict[str, Any]
30
+ app_variables: Optional[Dict[str, Any]]
31
+ license_variables: Optional[Dict[str, Any]]
32
+ key_id: Optional[str]
33
+
34
+
35
+ class ValidateLicenseFailure(TypedDict):
36
+ valid: Literal[False]
37
+ code: str
38
+ error: str
39
+
40
+
41
+ ValidateLicenseResult = Union[ValidateLicenseSuccess, ValidateLicenseFailure]
42
+
43
+
22
44
  KNOWN_SERVER_ERRORS = {
23
45
  "invalid_app",
24
46
  "invalid_key",
@@ -43,7 +65,7 @@ class AuthForgeClient:
43
65
  self,
44
66
  app_id: str,
45
67
  app_secret: str,
46
- public_key: str,
68
+ public_key: PublicKeyArg,
47
69
  heartbeat_mode: str,
48
70
  heartbeat_interval: int = 900,
49
71
  api_base_url: str = DEFAULT_API_BASE_URL,
@@ -56,8 +78,11 @@ class AuthForgeClient:
56
78
  raise ValueError("app_id must be a non-empty string")
57
79
  if not app_secret or not isinstance(app_secret, str):
58
80
  raise ValueError("app_secret must be a non-empty string")
59
- if not public_key or not isinstance(public_key, str):
60
- raise ValueError("public_key must be a non-empty base64 string")
81
+ public_key_list = self._normalize_public_key_list(public_key)
82
+ if not public_key_list:
83
+ raise ValueError(
84
+ "public_key must be a non-empty base64 string or list of base64 strings"
85
+ )
61
86
  mode = (heartbeat_mode or "").upper()
62
87
  if mode not in {"LOCAL", "SERVER"}:
63
88
  raise ValueError("heartbeat_mode must be LOCAL or SERVER")
@@ -66,7 +91,11 @@ class AuthForgeClient:
66
91
 
67
92
  self.app_id = app_id
68
93
  self.app_secret = app_secret
69
- self.public_key = public_key
94
+ # `public_key` is the historical public attribute. We now hold the
95
+ # full trust list to support key rotation, but expose the first entry
96
+ # as `public_key` for callers that read it directly.
97
+ self.public_keys: List[str] = public_key_list
98
+ self.public_key = public_key_list[0]
70
99
  self.heartbeat_mode = mode
71
100
  self.heartbeat_interval = int(heartbeat_interval)
72
101
  self.api_base_url = api_base_url.rstrip("/")
@@ -95,7 +124,9 @@ class AuthForgeClient:
95
124
  self._license_variables: Optional[Dict[str, Any]] = None
96
125
  self._authenticated = False
97
126
  self._hwid = self._resolve_hwid(hwid_override)
98
- self._ed25519_public_key = self._load_public_key(public_key)
127
+ self._ed25519_public_keys: List[Ed25519PublicKey] = [
128
+ self._load_public_key(k) for k in public_key_list
129
+ ]
99
130
 
100
131
  def login(self, license_key: str) -> bool:
101
132
  if not license_key or not isinstance(license_key, str):
@@ -109,6 +140,36 @@ class AuthForgeClient:
109
140
  self._fail("login_failed", exc)
110
141
  return False
111
142
 
143
+ def validate_license(self, license_key: str) -> ValidateLicenseResult:
144
+ """Validate like :meth:`login` (same /auth/validate + signatures) without storing
145
+ session state or starting the heartbeat thread."""
146
+ if not license_key or not isinstance(license_key, str):
147
+ raise ValueError("license_key must be a non-empty string")
148
+ try:
149
+ body: Dict[str, Any] = {
150
+ "appId": self.app_id,
151
+ "appSecret": self.app_secret,
152
+ "licenseKey": license_key,
153
+ "hwid": self._hwid,
154
+ "nonce": self._generate_nonce(),
155
+ }
156
+ if self.ttl_seconds is not None:
157
+ body["ttlSeconds"] = self.ttl_seconds
158
+ response_obj = self._post_json("/auth/validate", body, skip_failure_hook=True)
159
+ expected_nonce = str(body.get("nonce", "")).strip()
160
+ parsed = self._parse_validate_success(response_obj, expected_nonce)
161
+ return {
162
+ "valid": True,
163
+ "session_token": parsed["session_token"],
164
+ "expires_in": parsed["expires_in"],
165
+ "session_data": parsed["session_data"],
166
+ "app_variables": parsed["app_variables"],
167
+ "license_variables": parsed["license_variables"],
168
+ "key_id": parsed["key_id"],
169
+ }
170
+ except Exception as exc:
171
+ return {"valid": False, "code": str(exc), "error": str(exc)}
172
+
112
173
  def self_ban(
113
174
  self,
114
175
  *,
@@ -250,13 +311,9 @@ class AuthForgeClient:
250
311
  context="validate",
251
312
  )
252
313
 
253
- def _apply_signed_response(
254
- self,
255
- response_obj: Dict[str, Any],
256
- expected_nonce: str,
257
- license_key: Optional[str],
258
- context: str,
259
- ) -> None:
314
+ def _parse_validate_success(
315
+ self, response_obj: Dict[str, Any], expected_nonce: str
316
+ ) -> Dict[str, Any]:
260
317
  status = response_obj.get("status")
261
318
  if not self._is_success_status(status):
262
319
  error_code = self._extract_server_error(response_obj)
@@ -288,21 +345,46 @@ class AuthForgeClient:
288
345
  if expires_in is None:
289
346
  raise ValueError("missing_expiresIn")
290
347
 
348
+ return {
349
+ "session_token": session_token,
350
+ "expires_in": int(expires_in),
351
+ "session_data": dict(payload_json),
352
+ "app_variables": self._extract_optional_map(payload_json.get("appVariables")),
353
+ "license_variables": self._extract_optional_map(
354
+ payload_json.get("licenseVariables")
355
+ ),
356
+ "key_id": key_id if isinstance(key_id, str) else None,
357
+ "raw_payload_b64": raw_payload_b64,
358
+ "signature": signature,
359
+ }
360
+
361
+ def _apply_signed_response(
362
+ self,
363
+ response_obj: Dict[str, Any],
364
+ expected_nonce: str,
365
+ license_key: Optional[str],
366
+ context: str,
367
+ ) -> None:
368
+ parsed = self._parse_validate_success(response_obj, expected_nonce)
369
+ _ = context
370
+
291
371
  with self._lock:
292
372
  if license_key is not None:
293
373
  self._license_key = license_key
294
- self._session_token = session_token
295
- self._session_expires_in = int(expires_in)
374
+ self._session_token = parsed["session_token"]
375
+ self._session_expires_in = int(parsed["expires_in"])
296
376
  self._last_nonce = expected_nonce
297
- self._raw_payload_b64 = raw_payload_b64
298
- self._signature = signature
299
- self._key_id = key_id
300
- self._session_data = dict(payload_json)
301
- self._app_variables = self._extract_optional_map(payload_json.get("appVariables"))
302
- self._license_variables = self._extract_optional_map(payload_json.get("licenseVariables"))
377
+ self._raw_payload_b64 = parsed["raw_payload_b64"]
378
+ self._signature = parsed["signature"]
379
+ self._key_id = parsed["key_id"]
380
+ self._session_data = dict(parsed["session_data"])
381
+ self._app_variables = parsed["app_variables"]
382
+ self._license_variables = parsed["license_variables"]
303
383
  self._authenticated = True
304
384
 
305
- def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
385
+ def _post_json(
386
+ self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
387
+ ) -> Dict[str, Any]:
306
388
  url = f"{self.api_base_url}{path}"
307
389
  body = dict(data)
308
390
  rate_attempt = 0
@@ -342,7 +424,8 @@ class AuthForgeClient:
342
424
  network_attempt += 1
343
425
  time.sleep(NETWORK_RETRY_DELAY)
344
426
  continue
345
- self._fail("network_error", exc)
427
+ if not skip_failure_hook:
428
+ self._fail("network_error", exc)
346
429
  raise RuntimeError(f"url_error: {exc}") from exc
347
430
 
348
431
  is_rate_limited = (
@@ -483,13 +566,42 @@ class AuthForgeClient:
483
566
  )
484
567
  except Exception as exc:
485
568
  raise ValueError("invalid_signature_encoding") from exc
486
- try:
487
- self._ed25519_public_key.verify(
488
- signature_bytes,
489
- raw_payload_b64.encode("utf-8"),
490
- )
491
- except InvalidSignature as exc:
492
- raise ValueError("signature_mismatch") from exc
569
+ # During a key rotation the SDK may be pinned to the previous key
570
+ # while a new server-side key signs responses (or vice-versa). Trust
571
+ # any key in the configured list.
572
+ payload_bytes = raw_payload_b64.encode("utf-8")
573
+ for key in self._ed25519_public_keys:
574
+ try:
575
+ key.verify(signature_bytes, payload_bytes)
576
+ return
577
+ except InvalidSignature:
578
+ continue
579
+ raise ValueError("signature_mismatch")
580
+
581
+ @staticmethod
582
+ def _normalize_public_key_list(value: PublicKeyArg) -> List[str]:
583
+ """Coerce the public_key constructor arg to a list of base64 strings.
584
+
585
+ Accepts:
586
+ - "abc..." single-key historical contract
587
+ - ["abc...", "def..."] current first, previous after
588
+ - "abc...,def..." env-var convenience form
589
+ """
590
+ keys: List[str] = []
591
+ candidates: Iterable[Any]
592
+ if isinstance(value, str):
593
+ candidates = value.split(",") if "," in value else [value]
594
+ elif isinstance(value, Sequence):
595
+ candidates = value
596
+ else:
597
+ return []
598
+ for entry in candidates:
599
+ if not isinstance(entry, str):
600
+ continue
601
+ trimmed = entry.strip()
602
+ if trimmed:
603
+ keys.append(trimmed)
604
+ return keys
493
605
 
494
606
  def _generate_nonce(self) -> str:
495
607
  return secrets.token_hex(16)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -94,7 +94,7 @@ client = AuthForgeClient(
94
94
 
95
95
  ## Billing
96
96
 
97
- - **1 `login()` call = 1 credit** (one `/auth/validate` debit).
97
+ - **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
98
98
  - **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
99
99
 
100
100
  A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A server app running 24/7 at a 1-minute interval burns ~145 credits/day — pick the interval based on how fast you need revocations to propagate (they always land on the **next** heartbeat).
@@ -104,6 +104,7 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
104
104
  | Method | Returns | Description |
105
105
  |---|---|---|
106
106
  | `login(license_key)` | `bool` | Validates key and stores signed session (`sessionToken`, `expiresIn`, `appVariables`, `licenseVariables`) |
107
+ | `validate_license(license_key)` | `ValidateLicenseResult` | Same `/auth/validate` + signatures as `login`; does not store session or start heartbeats; returns a dict with `valid` / `code` and **never** calls `on_failure` or `os._exit` |
107
108
  | `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
108
109
  | `logout()` | `None` | Stops heartbeat and clears all session/auth state |
109
110
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
@@ -121,6 +122,8 @@ A desktop app running 6h/day at a 15-minute interval burns ~3–4 credits/day. A
121
122
 
122
123
  If authentication fails (login rejected, heartbeat fails, signature mismatch, etc.), the SDK calls your `on_failure` callback if one is provided. If no callback is set, **the SDK calls `os._exit(1)` to terminate the process.** This is intentional — it prevents your app from running without a valid license.
123
124
 
125
+ **`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
126
+
124
127
  Recognized server errors:
125
128
  `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
126
129
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "authforge-sdk"
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes