Skip to content

Commit c08a7a0

Browse files
committed
feat: direct validateLicense endpoint. no heartbeat started.
1 parent 005a23c commit c08a7a0

5 files changed

Lines changed: 167 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ AuthForge is a license key validation service. Your app sends a license key + ha
99

1010
## Billing model (so you can pick sensible intervals)
1111

12-
- **1 `login()` = 1 credit** (one `/auth/validate` debit).
12+
- **1 `login()` or `validate_license()` = 1 credit** (one `/auth/validate` debit each).
1313
- **10 heartbeats = 1 credit** (billed on every 10th successful heartbeat per license).
1414
- Any `heartbeat_interval` is safe — from `1` (server apps) to `900` (15 min, desktop apps). Revocations always take effect on the **next** heartbeat regardless of interval.
1515

@@ -63,7 +63,7 @@ if __name__ == "__main__":
6363
| `heartbeat_mode` | `str` | yes || `"SERVER"` or `"LOCAL"` (case-insensitive) |
6464
| `heartbeat_interval` | `int` | no | `900` | Seconds between heartbeats (any value ≥ 1) |
6565
| `api_base_url` | `str` | no | `https://auth.authforge.cc` | API base URL |
66-
| `on_failure` | `Callable[[str, Optional[Exception]], None] \| None` | no | `None` | Called on login/heartbeat/network failure; if omitted, process exits via `os._exit(1)` |
66+
| `on_failure` | `Callable[[str, Optional[Exception]], None] \| None` | no | `None` | Called on login/heartbeat/network failure; if omitted, process exits via `os._exit(1)` (not used by `validate_license`) |
6767
| `request_timeout` | `int` | no | `15` | HTTP timeout (seconds) |
6868
| `ttl_seconds` | `int \| None` | no | `None` (server default: 86400) | Requested session token lifetime. Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
6969
| `hwid_override` | `str \| None` | no | `None` | Optional custom HWID/subject string. When set to a non-empty value (for example `tg:123456789`), the SDK sends it instead of generating a machine fingerprint. |
@@ -75,6 +75,7 @@ For Telegram/Discord bot flows, prefer immutable IDs (`tg:<user_id>`, `discord:<
7575
| Method | Returns | Description |
7676
|--------|---------|-------------|
7777
| `login(license_key: str)` | `bool` | Validates license, verifies signatures, starts heartbeat thread |
78+
| `validate_license(license_key: str)` | `ValidateLicenseResult` | Same validate + signatures as login; no session persistence or heartbeat; **never** calls `on_failure` or `os._exit` |
7879
| `logout()` | `None` | Stops heartbeat and clears session state |
7980
| `is_authenticated()` | `bool` | Whether a session token is present and marked authenticated |
8081
| `get_session_data()` | `dict \| None` | Decoded signed payload map |

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ client = AuthForgeClient(
6767

6868
## Billing
6969

70-
- **1 `login()` call = 1 credit** (one `/auth/validate` debit).
70+
- **1 `login()` or `validate_license()` call = 1 credit** (one `/auth/validate` debit each).
7171
- **10 heartbeats on the same license = 1 credit** (billed every 10th successful heartbeat).
7272

7373
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
7777
| Method | Returns | Description |
7878
|---|---|---|
7979
| `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` |
8081
| `self_ban(...)` | `dict` | Requests `/auth/selfban` to blacklist HWID/IP and optionally revoke (session-authenticated only) |
8182
| `logout()` | `None` | Stops heartbeat and clears all session/auth state |
8283
| `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
9495

9596
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.
9697

98+
**`validate_license()`** does not trigger `on_failure` or `os._exit` — check `result["valid"]` and `result["code"]`.
99+
97100
Recognized server errors:
98101
`invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`
99102

authforge.py

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,33 @@
1111
import urllib.error
1212
import urllib.request
1313
import uuid
14-
from typing import Any, Callable, Dict, Optional
14+
from typing import Any, Callable, Dict, Literal, Optional, TypedDict, Union
1515
from cryptography.exceptions import InvalidSignature
1616
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
1717

1818

1919
DEFAULT_API_BASE_URL = "https://auth.authforge.cc"
2020
RATE_LIMIT_RETRY_DELAYS = (2, 5)
2121
NETWORK_RETRY_DELAY = 2
22+
class ValidateLicenseSuccess(TypedDict):
23+
valid: Literal[True]
24+
session_token: str
25+
expires_in: int
26+
session_data: Dict[str, Any]
27+
app_variables: Optional[Dict[str, Any]]
28+
license_variables: Optional[Dict[str, Any]]
29+
key_id: Optional[str]
30+
31+
32+
class ValidateLicenseFailure(TypedDict):
33+
valid: Literal[False]
34+
code: str
35+
error: str
36+
37+
38+
ValidateLicenseResult = Union[ValidateLicenseSuccess, ValidateLicenseFailure]
39+
40+
2241
KNOWN_SERVER_ERRORS = {
2342
"invalid_app",
2443
"invalid_key",
@@ -109,6 +128,36 @@ def login(self, license_key: str) -> bool:
109128
self._fail("login_failed", exc)
110129
return False
111130

131+
def validate_license(self, license_key: str) -> ValidateLicenseResult:
132+
"""Validate like :meth:`login` (same /auth/validate + signatures) without storing
133+
session state or starting the heartbeat thread."""
134+
if not license_key or not isinstance(license_key, str):
135+
raise ValueError("license_key must be a non-empty string")
136+
try:
137+
body: Dict[str, Any] = {
138+
"appId": self.app_id,
139+
"appSecret": self.app_secret,
140+
"licenseKey": license_key,
141+
"hwid": self._hwid,
142+
"nonce": self._generate_nonce(),
143+
}
144+
if self.ttl_seconds is not None:
145+
body["ttlSeconds"] = self.ttl_seconds
146+
response_obj = self._post_json("/auth/validate", body, skip_failure_hook=True)
147+
expected_nonce = str(body.get("nonce", "")).strip()
148+
parsed = self._parse_validate_success(response_obj, expected_nonce)
149+
return {
150+
"valid": True,
151+
"session_token": parsed["session_token"],
152+
"expires_in": parsed["expires_in"],
153+
"session_data": parsed["session_data"],
154+
"app_variables": parsed["app_variables"],
155+
"license_variables": parsed["license_variables"],
156+
"key_id": parsed["key_id"],
157+
}
158+
except Exception as exc:
159+
return {"valid": False, "code": str(exc), "error": str(exc)}
160+
112161
def self_ban(
113162
self,
114163
*,
@@ -250,13 +299,9 @@ def _validate_and_store(self, license_key: str) -> None:
250299
context="validate",
251300
)
252301

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:
302+
def _parse_validate_success(
303+
self, response_obj: Dict[str, Any], expected_nonce: str
304+
) -> Dict[str, Any]:
260305
status = response_obj.get("status")
261306
if not self._is_success_status(status):
262307
error_code = self._extract_server_error(response_obj)
@@ -288,21 +333,46 @@ def _apply_signed_response(
288333
if expires_in is None:
289334
raise ValueError("missing_expiresIn")
290335

336+
return {
337+
"session_token": session_token,
338+
"expires_in": int(expires_in),
339+
"session_data": dict(payload_json),
340+
"app_variables": self._extract_optional_map(payload_json.get("appVariables")),
341+
"license_variables": self._extract_optional_map(
342+
payload_json.get("licenseVariables")
343+
),
344+
"key_id": key_id if isinstance(key_id, str) else None,
345+
"raw_payload_b64": raw_payload_b64,
346+
"signature": signature,
347+
}
348+
349+
def _apply_signed_response(
350+
self,
351+
response_obj: Dict[str, Any],
352+
expected_nonce: str,
353+
license_key: Optional[str],
354+
context: str,
355+
) -> None:
356+
parsed = self._parse_validate_success(response_obj, expected_nonce)
357+
_ = context
358+
291359
with self._lock:
292360
if license_key is not None:
293361
self._license_key = license_key
294-
self._session_token = session_token
295-
self._session_expires_in = int(expires_in)
362+
self._session_token = parsed["session_token"]
363+
self._session_expires_in = int(parsed["expires_in"])
296364
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"))
365+
self._raw_payload_b64 = parsed["raw_payload_b64"]
366+
self._signature = parsed["signature"]
367+
self._key_id = parsed["key_id"]
368+
self._session_data = dict(parsed["session_data"])
369+
self._app_variables = parsed["app_variables"]
370+
self._license_variables = parsed["license_variables"]
303371
self._authenticated = True
304372

305-
def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
373+
def _post_json(
374+
self, path: str, data: Dict[str, Any], *, skip_failure_hook: bool = False
375+
) -> Dict[str, Any]:
306376
url = f"{self.api_base_url}{path}"
307377
body = dict(data)
308378
rate_attempt = 0
@@ -342,7 +412,8 @@ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
342412
network_attempt += 1
343413
time.sleep(NETWORK_RETRY_DELAY)
344414
continue
345-
self._fail("network_error", exc)
415+
if not skip_failure_hook:
416+
self._fail("network_error", exc)
346417
raise RuntimeError(f"url_error: {exc}") from exc
347418

348419
is_rate_limited = (

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "authforge-sdk"
7-
version = "1.0.2"
7+
version = "1.0.3"
88
description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
99
readme = "README.md"
1010
requires-python = ">=3.9"

test_authforge.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,76 @@ def test_invalid_vectors_fail(self) -> None:
5151
self.assertEqual(ctx.exception.args[0], "signature_mismatch")
5252

5353

54+
class ValidateLicenseTests(unittest.TestCase):
55+
def test_validate_license_success_no_heartbeat(self) -> None:
56+
vectors = _load_test_vectors()
57+
success_case = next(
58+
case for case in vectors["cases"] if case["id"] == "validate_success"
59+
)
60+
nonce = "nonce-validate-001"
61+
mock_resp = MagicMock()
62+
mock_resp.status = 200
63+
mock_resp.read.return_value = json.dumps(
64+
{
65+
"status": "ok",
66+
"payload": success_case["payload"],
67+
"signature": success_case["signature"],
68+
"keyId": "signing-key-1",
69+
},
70+
separators=(",", ":"),
71+
).encode("utf-8")
72+
73+
urlopen_cm = MagicMock()
74+
urlopen_cm.__enter__.return_value = mock_resp
75+
urlopen_cm.__exit__.return_value = None
76+
77+
with (
78+
patch("authforge.urllib.request.urlopen", return_value=urlopen_cm),
79+
patch.object(AuthForgeClient, "_generate_nonce", return_value=nonce),
80+
):
81+
client = AuthForgeClient(
82+
"app-id",
83+
"app-secret",
84+
vectors["publicKey"],
85+
"LOCAL",
86+
heartbeat_interval=86400,
87+
)
88+
result = client.validate_license("license-key")
89+
90+
self.assertTrue(result["valid"])
91+
self.assertFalse(client._heartbeat_started)
92+
self.assertFalse(client.is_authenticated())
93+
self.assertEqual(result["session_token"], "session.validate.token")
94+
self.assertEqual(result["app_variables"], {"tier": "pro"})
95+
96+
def test_validate_license_failure_no_heartbeat(self) -> None:
97+
vectors = _load_test_vectors()
98+
mock_resp = MagicMock()
99+
mock_resp.status = 200
100+
mock_resp.read.return_value = json.dumps(
101+
{"status": "invalid_key", "error": "invalid_key"},
102+
separators=(",", ":"),
103+
).encode("utf-8")
104+
105+
urlopen_cm = MagicMock()
106+
urlopen_cm.__enter__.return_value = mock_resp
107+
urlopen_cm.__exit__.return_value = None
108+
109+
with patch("authforge.urllib.request.urlopen", return_value=urlopen_cm):
110+
client = AuthForgeClient(
111+
"app-id",
112+
"app-secret",
113+
vectors["publicKey"],
114+
"LOCAL",
115+
heartbeat_interval=86400,
116+
)
117+
result = client.validate_license("bad")
118+
119+
self.assertFalse(result["valid"])
120+
self.assertEqual(result["code"], "invalid_key")
121+
self.assertFalse(client._heartbeat_started)
122+
123+
54124
class LoginFlowTests(unittest.TestCase):
55125
def test_login_parses_and_stores_signed_payload(self) -> None:
56126
vectors = _load_test_vectors()

0 commit comments

Comments
 (0)