authforge-sdk 1.3.1__tar.gz → 1.4.0__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.
- {authforge_sdk-1.3.1/authforge_sdk.egg-info → authforge_sdk-1.4.0}/PKG-INFO +42 -10
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/README.md +41 -9
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/authforge.py +216 -46
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0/authforge_sdk.egg-info}/PKG-INFO +42 -10
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/pyproject.toml +1 -1
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/LICENSE +0 -0
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/authforge_sdk.egg-info/SOURCES.txt +0 -0
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/authforge_sdk.egg-info/dependency_links.txt +0 -0
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/authforge_sdk.egg-info/requires.txt +0 -0
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/authforge_sdk.egg-info/top_level.txt +0 -0
- {authforge_sdk-1.3.1 → authforge_sdk-1.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
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
|
|
@@ -153,7 +153,7 @@ first (primary) entry.
|
|
|
153
153
|
|
|
154
154
|
**Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
|
|
155
155
|
|
|
156
|
-
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
|
|
156
|
+
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
|
|
157
157
|
|
|
158
158
|
Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
|
|
159
159
|
|
|
@@ -247,25 +247,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
247
247
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
248
248
|
|
|
249
249
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
250
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error
|
|
250
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
251
251
|
|
|
252
252
|
Request retries are automatic inside the internal HTTP layer:
|
|
253
|
-
- `rate_limited
|
|
253
|
+
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
254
|
+
- `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
|
|
254
255
|
- network failure: retry once after 2s
|
|
255
256
|
- every retry regenerates a fresh nonce
|
|
256
257
|
|
|
258
|
+
### Background check failures
|
|
259
|
+
|
|
260
|
+
Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
|
|
261
|
+
|
|
262
|
+
- `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
|
|
263
|
+
- `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
|
|
264
|
+
|
|
265
|
+
A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
|
|
266
|
+
|
|
267
|
+
| Kind | Codes | What the SDK does |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
270
|
+
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
271
|
+
|
|
272
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
|
|
273
|
+
|
|
274
|
+
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
275
|
+
|
|
276
|
+
To tolerate short outages but exit on a definitive answer:
|
|
277
|
+
|
|
257
278
|
```python
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
279
|
+
import os
|
|
280
|
+
import sys
|
|
281
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
282
|
+
|
|
283
|
+
def handle_auth_failure(reason, exc):
|
|
284
|
+
if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
|
|
285
|
+
# Connectivity problem or AuthForge overloaded: keep running. The SDK
|
|
286
|
+
# retries every heartbeat_interval and reports session_expired (fatal)
|
|
287
|
+
# once the ttl_seconds grace period is used up.
|
|
288
|
+
print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
|
|
289
|
+
return
|
|
290
|
+
code = exc.code if isinstance(exc, AuthForgeError) else exc
|
|
291
|
+
print(f"License check failed: {reason} ({code})", file=sys.stderr)
|
|
292
|
+
# on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
|
|
293
|
+
os._exit(1)
|
|
264
294
|
|
|
265
295
|
client = AuthForgeClient(
|
|
266
296
|
app_id="YOUR_APP_ID",
|
|
267
297
|
app_secret="YOUR_APP_SECRET",
|
|
268
298
|
public_key="YOUR_PUBLIC_KEY",
|
|
299
|
+
online_heartbeat=True,
|
|
300
|
+
ttl_seconds=3600, # retry window for transient check-in failures
|
|
269
301
|
on_failure=handle_auth_failure,
|
|
270
302
|
)
|
|
271
303
|
```
|
|
@@ -125,7 +125,7 @@ first (primary) entry.
|
|
|
125
125
|
|
|
126
126
|
**Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
|
|
127
127
|
|
|
128
|
-
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
|
|
128
|
+
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
|
|
129
129
|
|
|
130
130
|
Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
|
|
131
131
|
|
|
@@ -219,25 +219,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
219
219
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
220
220
|
|
|
221
221
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
222
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error
|
|
222
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
223
223
|
|
|
224
224
|
Request retries are automatic inside the internal HTTP layer:
|
|
225
|
-
- `rate_limited
|
|
225
|
+
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
226
|
+
- `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
|
|
226
227
|
- network failure: retry once after 2s
|
|
227
228
|
- every retry regenerates a fresh nonce
|
|
228
229
|
|
|
230
|
+
### Background check failures
|
|
231
|
+
|
|
232
|
+
Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
|
|
233
|
+
|
|
234
|
+
- `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
|
|
235
|
+
- `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
|
|
236
|
+
|
|
237
|
+
A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
|
|
238
|
+
|
|
239
|
+
| Kind | Codes | What the SDK does |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
242
|
+
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
243
|
+
|
|
244
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
|
|
245
|
+
|
|
246
|
+
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
247
|
+
|
|
248
|
+
To tolerate short outages but exit on a definitive answer:
|
|
249
|
+
|
|
229
250
|
```python
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
import os
|
|
252
|
+
import sys
|
|
253
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
254
|
+
|
|
255
|
+
def handle_auth_failure(reason, exc):
|
|
256
|
+
if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
|
|
257
|
+
# Connectivity problem or AuthForge overloaded: keep running. The SDK
|
|
258
|
+
# retries every heartbeat_interval and reports session_expired (fatal)
|
|
259
|
+
# once the ttl_seconds grace period is used up.
|
|
260
|
+
print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
|
|
261
|
+
return
|
|
262
|
+
code = exc.code if isinstance(exc, AuthForgeError) else exc
|
|
263
|
+
print(f"License check failed: {reason} ({code})", file=sys.stderr)
|
|
264
|
+
# on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
|
|
265
|
+
os._exit(1)
|
|
236
266
|
|
|
237
267
|
client = AuthForgeClient(
|
|
238
268
|
app_id="YOUR_APP_ID",
|
|
239
269
|
app_secret="YOUR_APP_SECRET",
|
|
240
270
|
public_key="YOUR_PUBLIC_KEY",
|
|
271
|
+
online_heartbeat=True,
|
|
272
|
+
ttl_seconds=3600, # retry window for transient check-in failures
|
|
241
273
|
on_failure=handle_auth_failure,
|
|
242
274
|
)
|
|
243
275
|
```
|
|
@@ -87,7 +87,7 @@ _ACTIVATION_REQUEST_VERSION = 1
|
|
|
87
87
|
_ACTIVATION_REQUEST_TYP = "authforge-activation-request"
|
|
88
88
|
_BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
|
|
89
89
|
_END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
|
|
90
|
-
_SDK_TAG = "python/1.
|
|
90
|
+
_SDK_TAG = "python/1.4.0"
|
|
91
91
|
_ARMOR_LINE_WIDTH = 64
|
|
92
92
|
_MAX_REQUEST_HWID = 256
|
|
93
93
|
_MAX_REQUEST_MACHINE_NAME = 128
|
|
@@ -492,9 +492,89 @@ KNOWN_SERVER_ERRORS = {
|
|
|
492
492
|
"revoke_requires_session",
|
|
493
493
|
"bad_request",
|
|
494
494
|
"malformed_request",
|
|
495
|
+
"demo_quota_exceeded",
|
|
495
496
|
"system_error",
|
|
496
497
|
}
|
|
497
498
|
|
|
499
|
+
# Well-formed AuthForge verdicts that end the session. This is the only list
|
|
500
|
+
# the classification uses: every other code is transient.
|
|
501
|
+
DEFINITIVE_ERROR_CODES = frozenset(
|
|
502
|
+
{
|
|
503
|
+
"revoked",
|
|
504
|
+
"expired",
|
|
505
|
+
"hwid_mismatch",
|
|
506
|
+
"blocked",
|
|
507
|
+
"session_expired",
|
|
508
|
+
"malformed_request",
|
|
509
|
+
"app_disabled",
|
|
510
|
+
"invalid_app",
|
|
511
|
+
"signature_mismatch",
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Reference list of the named transient codes. Informational only: any code
|
|
516
|
+
# outside DEFINITIVE_ERROR_CODES is transient, including every
|
|
517
|
+
# http_error_<status>, unexpected_response and codes the server adds later.
|
|
518
|
+
TRANSIENT_ERROR_CODES = frozenset(
|
|
519
|
+
{
|
|
520
|
+
"network_error",
|
|
521
|
+
"timeout",
|
|
522
|
+
"rate_limited",
|
|
523
|
+
"system_error",
|
|
524
|
+
"handler_error",
|
|
525
|
+
"invalid_json_response",
|
|
526
|
+
"response_not_json_object",
|
|
527
|
+
"unexpected_response",
|
|
528
|
+
"no_credits",
|
|
529
|
+
"demo_quota_exceeded",
|
|
530
|
+
"app_burn_cap_reached",
|
|
531
|
+
"bad_request",
|
|
532
|
+
"invalid_key",
|
|
533
|
+
"replay_detected",
|
|
534
|
+
"revoke_requires_session",
|
|
535
|
+
"missing_session_token",
|
|
536
|
+
"nonce_mismatch",
|
|
537
|
+
"unknown_error",
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
_SERVER_ERROR_CODE_RE = re.compile(r"[a-z][a-z0-9_]{0,63}")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class AuthForgeError(ValueError):
|
|
544
|
+
"""A failure with a machine-readable ``code``: the server's error code
|
|
545
|
+
(``revoked``, ``hwid_mismatch``, ...) or an SDK code (``network_error``,
|
|
546
|
+
``timeout``, ``http_error_502``, ...). ``str(exc)`` is the human message,
|
|
547
|
+
which equals ``code`` for server errors."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, code: str, message: Optional[str] = None) -> None:
|
|
550
|
+
super().__init__(message if message is not None else code)
|
|
551
|
+
self.code = code
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def transient(self) -> bool:
|
|
555
|
+
"""True when retrying later can succeed (see :func:`is_transient_error`)."""
|
|
556
|
+
return is_transient_error(self.code)
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def fatal(self) -> bool:
|
|
560
|
+
"""True when AuthForge definitively rejected the session or license."""
|
|
561
|
+
return not self.transient
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def is_transient_error(error: Union[str, BaseException, None]) -> bool:
|
|
565
|
+
"""Classify a failure code (or an exception from ``on_failure``).
|
|
566
|
+
|
|
567
|
+
Definitive (fatal) only for the codes in :data:`DEFINITIVE_ERROR_CODES`.
|
|
568
|
+
Everything else is transient: network failures, ``rate_limited``,
|
|
569
|
+
``no_credits``, ``http_error_<status>``, ``unexpected_response``, unknown
|
|
570
|
+
codes, ... ``None`` and exceptions other than :class:`AuthForgeError`
|
|
571
|
+
carry no AuthForge verdict and are transient too.
|
|
572
|
+
"""
|
|
573
|
+
code = error.code if isinstance(error, AuthForgeError) else error
|
|
574
|
+
if not isinstance(code, str):
|
|
575
|
+
return True
|
|
576
|
+
return code not in DEFINITIVE_ERROR_CODES
|
|
577
|
+
|
|
498
578
|
|
|
499
579
|
class AuthForgeClient:
|
|
500
580
|
def __init__(
|
|
@@ -573,6 +653,9 @@ class AuthForgeClient:
|
|
|
573
653
|
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
574
654
|
self._heartbeat_started = False
|
|
575
655
|
self._heartbeat_stop = threading.Event()
|
|
656
|
+
# Bumped whenever the stored session is replaced or cleared, so a
|
|
657
|
+
# heartbeat that started before logout()/login() can't write back.
|
|
658
|
+
self._session_generation = 0
|
|
576
659
|
|
|
577
660
|
self._license_key: Optional[str] = None
|
|
578
661
|
self._session_token: Optional[str] = None
|
|
@@ -880,31 +963,80 @@ class AuthForgeClient:
|
|
|
880
963
|
# the file's own expires_at is the only clock. Never start a thread.
|
|
881
964
|
if self._heartbeat_started or self._session_kind == "offline":
|
|
882
965
|
return
|
|
883
|
-
|
|
966
|
+
# A fresh event per thread: logout() stops this loop even if a
|
|
967
|
+
# later login() starts another one before this one wakes up.
|
|
968
|
+
stop = threading.Event()
|
|
969
|
+
self._heartbeat_stop = stop
|
|
884
970
|
self._heartbeat_started = True
|
|
885
971
|
self._heartbeat_thread = threading.Thread(
|
|
886
972
|
target=self._heartbeat_loop,
|
|
973
|
+
args=(stop,),
|
|
887
974
|
name="AuthForgeHeartbeat",
|
|
888
975
|
daemon=True,
|
|
889
976
|
)
|
|
890
977
|
self._heartbeat_thread.start()
|
|
891
978
|
|
|
892
|
-
def _heartbeat_loop(self) -> None:
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
if offline:
|
|
897
|
-
return
|
|
898
|
-
try:
|
|
899
|
-
if self.online_heartbeat:
|
|
900
|
-
self._server_heartbeat()
|
|
901
|
-
else:
|
|
902
|
-
self._grace_period_check()
|
|
903
|
-
except Exception as exc:
|
|
904
|
-
self._fail("heartbeat_failed", exc)
|
|
979
|
+
def _heartbeat_loop(self, stop: Optional[threading.Event] = None) -> None:
|
|
980
|
+
stop = stop if stop is not None else self._heartbeat_stop
|
|
981
|
+
while not stop.wait(self.heartbeat_interval):
|
|
982
|
+
if not self._heartbeat_tick():
|
|
905
983
|
break
|
|
906
984
|
|
|
907
|
-
def
|
|
985
|
+
def _heartbeat_tick(self) -> bool:
|
|
986
|
+
"""Run one background check. Returns ``False`` when the loop must stop.
|
|
987
|
+
|
|
988
|
+
Transient failures are reported and the loop keeps checking in on the
|
|
989
|
+
next interval. Definitive failures drop the stored session first, so
|
|
990
|
+
neither the grace period nor ``is_authenticated()`` keeps the app
|
|
991
|
+
running on it, and ``on_failure`` may call ``login()`` again.
|
|
992
|
+
``on_failure`` always runs with no SDK lock held.
|
|
993
|
+
"""
|
|
994
|
+
with self._lock:
|
|
995
|
+
offline = self._session_kind == "offline"
|
|
996
|
+
generation = self._session_generation
|
|
997
|
+
if offline:
|
|
998
|
+
return False
|
|
999
|
+
try:
|
|
1000
|
+
if self.online_heartbeat:
|
|
1001
|
+
self._server_heartbeat(generation)
|
|
1002
|
+
else:
|
|
1003
|
+
self._grace_period_check()
|
|
1004
|
+
return True
|
|
1005
|
+
except Exception as exc:
|
|
1006
|
+
error = self._heartbeat_error(exc)
|
|
1007
|
+
with self._lock:
|
|
1008
|
+
# The session this check ran for was logged out or replaced
|
|
1009
|
+
# meanwhile: its failure is moot and must not touch the new state.
|
|
1010
|
+
if generation != self._session_generation:
|
|
1011
|
+
return True
|
|
1012
|
+
if error.fatal:
|
|
1013
|
+
self._clear_session_locked()
|
|
1014
|
+
self._fail("heartbeat_failed", error)
|
|
1015
|
+
return error.transient
|
|
1016
|
+
|
|
1017
|
+
def _heartbeat_error(self, exc: Exception) -> AuthForgeError:
|
|
1018
|
+
if isinstance(exc, AuthForgeError):
|
|
1019
|
+
error = exc
|
|
1020
|
+
else:
|
|
1021
|
+
message = str(exc)
|
|
1022
|
+
code = message.split(":", 1)[0].strip().lower()
|
|
1023
|
+
if not _SERVER_ERROR_CODE_RE.fullmatch(code):
|
|
1024
|
+
code = "unknown_error"
|
|
1025
|
+
error = AuthForgeError(code, message)
|
|
1026
|
+
error.__cause__ = exc
|
|
1027
|
+
# A transient failure can't extend the session past its signed TTL.
|
|
1028
|
+
if error.transient and self._local_session_expired():
|
|
1029
|
+
expired = AuthForgeError("session_expired")
|
|
1030
|
+
expired.__cause__ = error
|
|
1031
|
+
return expired
|
|
1032
|
+
return error
|
|
1033
|
+
|
|
1034
|
+
def _local_session_expired(self) -> bool:
|
|
1035
|
+
with self._lock:
|
|
1036
|
+
expires_in = self._session_expires_in
|
|
1037
|
+
return expires_in is not None and int(time.time()) >= int(expires_in)
|
|
1038
|
+
|
|
1039
|
+
def _server_heartbeat(self, generation: Optional[int] = None) -> None:
|
|
908
1040
|
with self._lock:
|
|
909
1041
|
session_token = self._session_token
|
|
910
1042
|
hwid = self._hwid
|
|
@@ -917,13 +1049,35 @@ class AuthForgeClient:
|
|
|
917
1049
|
"nonce": self._generate_nonce(),
|
|
918
1050
|
"hwid": hwid,
|
|
919
1051
|
}
|
|
920
|
-
|
|
1052
|
+
# Network failures surface once, as heartbeat_failed / network_error.
|
|
1053
|
+
response_obj = self._post_json("/auth/heartbeat", body, skip_failure_hook=True)
|
|
1054
|
+
if not self._is_success_status(response_obj.get("status")):
|
|
1055
|
+
raise self._heartbeat_verdict(response_obj)
|
|
921
1056
|
expected_nonce = str(body.get("nonce", "")).strip()
|
|
922
1057
|
self._apply_signed_response(
|
|
923
1058
|
response_obj,
|
|
924
1059
|
expected_nonce=expected_nonce,
|
|
925
1060
|
license_key=None,
|
|
926
1061
|
context="heartbeat",
|
|
1062
|
+
generation=generation,
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def _heartbeat_verdict(self, response_obj: Dict[str, Any]) -> AuthForgeError:
|
|
1066
|
+
# Only {"status": "failed", "error": "<code>"} is an AuthForge verdict.
|
|
1067
|
+
# Anything else (a proxy's JSON, a half-written body) must not be able
|
|
1068
|
+
# to end the session with a definitive code.
|
|
1069
|
+
status = response_obj.get("status")
|
|
1070
|
+
error = response_obj.get("error")
|
|
1071
|
+
if (
|
|
1072
|
+
isinstance(status, str)
|
|
1073
|
+
and status.strip().lower() == "failed"
|
|
1074
|
+
and isinstance(error, str)
|
|
1075
|
+
and error.strip()
|
|
1076
|
+
):
|
|
1077
|
+
return AuthForgeError(self._extract_server_error(response_obj))
|
|
1078
|
+
return AuthForgeError(
|
|
1079
|
+
"unexpected_response",
|
|
1080
|
+
f"unexpected_response: status={status!r} error={error!r}",
|
|
927
1081
|
)
|
|
928
1082
|
|
|
929
1083
|
def _grace_period_check(self) -> None:
|
|
@@ -943,7 +1097,7 @@ class AuthForgeClient:
|
|
|
943
1097
|
|
|
944
1098
|
now = int(time.time())
|
|
945
1099
|
if now >= int(expires_in):
|
|
946
|
-
raise
|
|
1100
|
+
raise AuthForgeError("session_expired")
|
|
947
1101
|
|
|
948
1102
|
def _validate_and_store(self, license_key: str) -> None:
|
|
949
1103
|
body: Dict[str, Any] = {
|
|
@@ -969,8 +1123,7 @@ class AuthForgeClient:
|
|
|
969
1123
|
) -> Dict[str, Any]:
|
|
970
1124
|
status = response_obj.get("status")
|
|
971
1125
|
if not self._is_success_status(status):
|
|
972
|
-
|
|
973
|
-
raise ValueError(error_code)
|
|
1126
|
+
raise AuthForgeError(self._extract_server_error(response_obj))
|
|
974
1127
|
|
|
975
1128
|
raw_payload_b64 = self._require_str(response_obj, "payload")
|
|
976
1129
|
signature = self._require_str(response_obj, "signature")
|
|
@@ -1037,12 +1190,16 @@ class AuthForgeClient:
|
|
|
1037
1190
|
expected_nonce: str,
|
|
1038
1191
|
license_key: Optional[str],
|
|
1039
1192
|
context: str,
|
|
1193
|
+
generation: Optional[int] = None,
|
|
1040
1194
|
) -> None:
|
|
1041
1195
|
parsed = self._parse_validate_success(response_obj, expected_nonce)
|
|
1042
1196
|
_ = context
|
|
1043
1197
|
|
|
1044
1198
|
with self._lock:
|
|
1199
|
+
if generation is not None and generation != self._session_generation:
|
|
1200
|
+
return
|
|
1045
1201
|
if license_key is not None:
|
|
1202
|
+
self._session_generation += 1
|
|
1046
1203
|
self._license_key = license_key
|
|
1047
1204
|
self._session_token = parsed["session_token"]
|
|
1048
1205
|
self._session_kind = "online"
|
|
@@ -1089,7 +1246,7 @@ class AuthForgeClient:
|
|
|
1089
1246
|
detail = exc.read().decode("utf-8")
|
|
1090
1247
|
obj = self._parse_response_object(detail)
|
|
1091
1248
|
except Exception:
|
|
1092
|
-
raise
|
|
1249
|
+
raise AuthForgeError(f"http_error_{status_code}") from exc
|
|
1093
1250
|
data.clear()
|
|
1094
1251
|
data.update(body)
|
|
1095
1252
|
break
|
|
@@ -1100,11 +1257,15 @@ class AuthForgeClient:
|
|
|
1100
1257
|
continue
|
|
1101
1258
|
if not skip_failure_hook:
|
|
1102
1259
|
self._fail("network_error", exc)
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1260
|
+
reason = getattr(exc, "reason", exc)
|
|
1261
|
+
code = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network_error"
|
|
1262
|
+
raise AuthForgeError(code, f"url_error: {exc}") from exc
|
|
1263
|
+
|
|
1264
|
+
# no_credits / app_burn_cap_reached also use HTTP 429 but are not
|
|
1265
|
+
# worth retrying; only retry a genuine rate limit.
|
|
1266
|
+
server_error = self._extract_server_error(obj)
|
|
1267
|
+
is_rate_limited = server_error == "rate_limited" or (
|
|
1268
|
+
status_code == 429 and server_error == "unknown_error"
|
|
1108
1269
|
)
|
|
1109
1270
|
if is_rate_limited and rate_attempt < len(RATE_LIMIT_RETRY_DELAYS):
|
|
1110
1271
|
time.sleep(RATE_LIMIT_RETRY_DELAYS[rate_attempt])
|
|
@@ -1116,9 +1277,9 @@ class AuthForgeClient:
|
|
|
1116
1277
|
try:
|
|
1117
1278
|
obj = json.loads(raw_response)
|
|
1118
1279
|
except json.JSONDecodeError as exc:
|
|
1119
|
-
raise
|
|
1280
|
+
raise AuthForgeError("invalid_json_response") from exc
|
|
1120
1281
|
if not isinstance(obj, dict):
|
|
1121
|
-
raise
|
|
1282
|
+
raise AuthForgeError("response_not_json_object")
|
|
1122
1283
|
return obj
|
|
1123
1284
|
|
|
1124
1285
|
def _get_hwid(self) -> str:
|
|
@@ -1298,8 +1459,11 @@ class AuthForgeClient:
|
|
|
1298
1459
|
return text
|
|
1299
1460
|
|
|
1300
1461
|
def _extract_server_error(self, obj: Dict[str, Any]) -> str:
|
|
1301
|
-
|
|
1302
|
-
|
|
1462
|
+
# Pass through codes this SDK version doesn't know yet instead of
|
|
1463
|
+
# collapsing them into unknown_error.
|
|
1464
|
+
raw = obj.get("error")
|
|
1465
|
+
raw_error = raw.strip().lower() if isinstance(raw, str) else ""
|
|
1466
|
+
if raw_error in KNOWN_SERVER_ERRORS or _SERVER_ERROR_CODE_RE.fullmatch(raw_error):
|
|
1303
1467
|
return raw_error
|
|
1304
1468
|
status = str(obj.get("status", "")).strip().lower()
|
|
1305
1469
|
if status in KNOWN_SERVER_ERRORS:
|
|
@@ -1321,23 +1485,29 @@ class AuthForgeClient:
|
|
|
1321
1485
|
os._exit(1)
|
|
1322
1486
|
|
|
1323
1487
|
def logout(self) -> None:
|
|
1324
|
-
|
|
1488
|
+
"""Clear the session and stop background checks. Never joins the
|
|
1489
|
+
heartbeat thread, so it is safe to call from ``on_failure``."""
|
|
1325
1490
|
with self._lock:
|
|
1326
|
-
self.
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1491
|
+
self._clear_session_locked()
|
|
1492
|
+
|
|
1493
|
+
def _clear_session_locked(self) -> None:
|
|
1494
|
+
self._heartbeat_stop.set()
|
|
1495
|
+
self._session_generation += 1
|
|
1496
|
+
self._license_key = None
|
|
1497
|
+
self._session_token = None
|
|
1498
|
+
self._session_kind = None
|
|
1499
|
+
self._session_expires_in = None
|
|
1500
|
+
self._last_nonce = None
|
|
1501
|
+
self._raw_payload_b64 = None
|
|
1502
|
+
self._signature = None
|
|
1503
|
+
self._key_id = None
|
|
1504
|
+
self._session_data = None
|
|
1505
|
+
self._app_variables = None
|
|
1506
|
+
self._license_variables = None
|
|
1507
|
+
self._authenticated = False
|
|
1508
|
+
self._offline_license = None
|
|
1509
|
+
self._heartbeat_started = False
|
|
1510
|
+
self._heartbeat_thread = None
|
|
1341
1511
|
|
|
1342
1512
|
def is_authenticated(self) -> bool:
|
|
1343
1513
|
"""True for an online session (:meth:`login`) or an offline one
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: authforge-sdk
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
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
|
|
@@ -153,7 +153,7 @@ first (primary) entry.
|
|
|
153
153
|
|
|
154
154
|
**Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
|
|
155
155
|
|
|
156
|
-
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
|
|
156
|
+
**Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
|
|
157
157
|
|
|
158
158
|
Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
|
|
159
159
|
|
|
@@ -247,25 +247,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
|
|
|
247
247
|
**`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
|
|
248
248
|
|
|
249
249
|
Recognized server errors (`KNOWN_SERVER_ERRORS`):
|
|
250
|
-
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error
|
|
250
|
+
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
|
|
251
251
|
|
|
252
252
|
Request retries are automatic inside the internal HTTP layer:
|
|
253
|
-
- `rate_limited
|
|
253
|
+
- `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
|
|
254
|
+
- `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
|
|
254
255
|
- network failure: retry once after 2s
|
|
255
256
|
- every retry regenerates a fresh nonce
|
|
256
257
|
|
|
258
|
+
### Background check failures
|
|
259
|
+
|
|
260
|
+
Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
|
|
261
|
+
|
|
262
|
+
- `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
|
|
263
|
+
- `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
|
|
264
|
+
|
|
265
|
+
A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
|
|
266
|
+
|
|
267
|
+
| Kind | Codes | What the SDK does |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
|
|
270
|
+
| Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
|
|
271
|
+
|
|
272
|
+
Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
|
|
273
|
+
|
|
274
|
+
`on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
|
|
275
|
+
|
|
276
|
+
To tolerate short outages but exit on a definitive answer:
|
|
277
|
+
|
|
257
278
|
```python
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
279
|
+
import os
|
|
280
|
+
import sys
|
|
281
|
+
from authforge import AuthForgeClient, AuthForgeError
|
|
282
|
+
|
|
283
|
+
def handle_auth_failure(reason, exc):
|
|
284
|
+
if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
|
|
285
|
+
# Connectivity problem or AuthForge overloaded: keep running. The SDK
|
|
286
|
+
# retries every heartbeat_interval and reports session_expired (fatal)
|
|
287
|
+
# once the ttl_seconds grace period is used up.
|
|
288
|
+
print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
|
|
289
|
+
return
|
|
290
|
+
code = exc.code if isinstance(exc, AuthForgeError) else exc
|
|
291
|
+
print(f"License check failed: {reason} ({code})", file=sys.stderr)
|
|
292
|
+
# on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
|
|
293
|
+
os._exit(1)
|
|
264
294
|
|
|
265
295
|
client = AuthForgeClient(
|
|
266
296
|
app_id="YOUR_APP_ID",
|
|
267
297
|
app_secret="YOUR_APP_SECRET",
|
|
268
298
|
public_key="YOUR_PUBLIC_KEY",
|
|
299
|
+
online_heartbeat=True,
|
|
300
|
+
ttl_seconds=3600, # retry window for transient check-in failures
|
|
269
301
|
on_failure=handle_auth_failure,
|
|
270
302
|
)
|
|
271
303
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "authforge-sdk"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|