Skip to content

Commit da8c312

Browse files
committed
Session TTL & Heartbeats redesign
1 parent 28f5dc2 commit da8c312

4 files changed

Lines changed: 163 additions & 131 deletions

File tree

AGENTS.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,18 @@ int main() {
5757
| `appId` | `std::string` | yes || Application ID |
5858
| `appSecret` | `std::string` | yes || Application secret |
5959
| `heartbeatMode` | `std::string` | yes || `"SERVER"` or `"LOCAL"` |
60-
| `heartbeatInterval` | `int` | no | `900` | Seconds between heartbeats |
60+
| `heartbeatInterval` | `int` | no | `900` | Seconds between heartbeats (any value ≥ 1 is supported; revocations apply on the next heartbeat) |
6161
| `apiBaseUrl` | `std::string` | no | `kDefaultApiBaseUrl` (`https://auth.authforge.cc`) | API base URL |
6262
| `onFailure` | `std::function<void(const std::string&, const std::exception*)>` | no | `nullptr` | Failure callback; if null, `std::exit(1)` |
6363
| `requestTimeout` | `int` | no | `15` | HTTP timeout (seconds) |
64+
| `ttlSeconds` | `int` | no | `0` (server default: 86400) | Requested session token lifetime. `0` means "server default". Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
65+
66+
## Billing model
67+
68+
- `Login()` calls `/auth/validate` and costs **1 credit**.
69+
- Heartbeats cost **1 credit per 10 successful calls** (billed on every 10th heartbeat).
70+
- Any heartbeat interval ≥ 1 second is economically safe — cost scales with how many heartbeats you send, not how often.
71+
- Revocations take effect on the **next** heartbeat regardless of interval.
6472

6573
## Methods
6674

@@ -77,6 +85,9 @@ int main() {
7785

7886
invalid_app, invalid_key, expired, revoked, hwid_mismatch, no_credits, blocked, rate_limited, replay_detected, session_expired, app_disabled, bad_request
7987

88+
Notes:
89+
- `rate_limited` and `replay_detected` are only returned from `/auth/validate`. Heartbeats are not IP rate-limited and do not enforce nonce replay.
90+
8091
## Common patterns
8192

8293
### Reading license variables (feature gating)

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,18 @@ target_link_libraries(yourapp PRIVATE AuthForge::authforge_sdk)
6464
| `appSecret` | string | required | Your application secret from the AuthForge dashboard |
6565
| `publicKey` | string | required | App Ed25519 public key (base64) from dashboard |
6666
| `heartbeatMode` | string | required | `"SERVER"` or `"LOCAL"` (see below) |
67-
| `heartbeatInterval` | int | `900` | Seconds between heartbeat checks (default 15 min) |
67+
| `heartbeatInterval` | int | `900` | Seconds between heartbeat checks (any value ≥ 1; default 15 min) |
6868
| `apiBaseUrl` | string | `https://auth.authforge.cc` | API endpoint |
6969
| `onFailure` | std::function | `nullptr` | Callback `(const string&, const exception*)` on auth failure |
7070
| `requestTimeout` | int | `15` | HTTP request timeout in seconds |
71+
| `ttlSeconds` | int | `0` (server default: 86400) | Requested session token lifetime. `0` means "server default". Server clamps to `[3600, 604800]`; preserved across heartbeat refreshes. |
72+
73+
## Billing
74+
75+
- **One `Login()` call = 1 credit** (one `/auth/validate` debit).
76+
- **10 heartbeats on the same session = 1 credit** (debited on every 10th successful heartbeat).
77+
78+
Any heartbeat interval is safe economically: 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. Choose your interval based on how quickly you need revocations to propagate (they always land on the **next** heartbeat).
7179

7280
## Methods
7381

authforge_sdk.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ AuthForgeClient::AuthForgeClient(
180180
int heartbeatInterval,
181181
std::string apiBaseUrl,
182182
std::function<void(const std::string &, const std::exception *)> onFailure,
183-
int requestTimeout)
183+
int requestTimeout,
184+
int ttlSeconds)
184185
: appId_(std::move(appId)),
185186
appSecret_(std::move(appSecret)),
186187
publicKey_(std::move(publicKey)),
@@ -189,6 +190,7 @@ AuthForgeClient::AuthForgeClient(
189190
apiBaseUrl_(std::move(apiBaseUrl)),
190191
onFailure_(std::move(onFailure)),
191192
requestTimeout_(requestTimeout),
193+
ttlSeconds_(ttlSeconds > 0 ? ttlSeconds : 0),
192194
heartbeatStarted_(false) {
193195
if (appId_.empty()) {
194196
throw std::invalid_argument("app_id must be a non-empty string");
@@ -328,13 +330,19 @@ void AuthForgeClient::LocalHeartbeat() {
328330

329331
void AuthForgeClient::ValidateAndStore(const std::string &licenseKey) {
330332
const std::string nonce = GenerateNonceHex32();
331-
const std::string body = BuildJsonBody({
333+
std::string body = BuildJsonBody({
332334
{"appId", appId_},
333335
{"appSecret", appSecret_},
334336
{"licenseKey", licenseKey},
335337
{"hwid", hwid_},
336338
{"nonce", nonce},
337339
});
340+
// BuildJsonBody always emits string values; splice ttlSeconds in as a raw
341+
// integer when the caller requested a custom session lifetime.
342+
if (ttlSeconds_ > 0 && body.size() >= 2 && body.back() == '}') {
343+
body.pop_back();
344+
body += ",\"ttlSeconds\":" + std::to_string(ttlSeconds_) + "}";
345+
}
338346
std::string usedNonce = nonce;
339347
const std::string response = PostJson("/auth/validate", body, &usedNonce);
340348
ApplySignedResponse(response, usedNonce, licenseKey, SigningContext::Validate);

authforge_sdk.h

Lines changed: 132 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,132 @@
1-
#pragma once
2-
3-
#include <cstdint>
4-
#include <exception>
5-
#include <functional>
6-
#include <mutex>
7-
#include <optional>
8-
#include <unordered_set>
9-
#include <string>
10-
#include <utility>
11-
#include <vector>
12-
13-
namespace authforge {
14-
15-
class AuthForgeClient {
16-
public:
17-
static constexpr const char *kDefaultApiBaseUrl = "https://auth.authforge.cc";
18-
19-
AuthForgeClient(
20-
std::string appId,
21-
std::string appSecret,
22-
std::string publicKey,
23-
std::string heartbeatMode,
24-
int heartbeatInterval = 900,
25-
std::string apiBaseUrl = kDefaultApiBaseUrl,
26-
std::function<void(const std::string &, const std::exception *)> onFailure = nullptr,
27-
int requestTimeout = 15);
28-
29-
bool Login(const std::string &licenseKey);
30-
void Logout();
31-
bool IsAuthenticated() const;
32-
std::optional<std::string> GetSessionDataJson() const;
33-
std::optional<std::string> GetAppVariablesJson() const;
34-
std::optional<std::string> GetLicenseVariablesJson() const;
35-
36-
private:
37-
struct JsonValue {
38-
bool exists = false;
39-
bool isString = false;
40-
std::string value;
41-
};
42-
43-
enum class SigningContext { Validate, Heartbeat };
44-
45-
void StartHeartbeatOnce();
46-
void HeartbeatLoop() noexcept;
47-
void ServerHeartbeat();
48-
void LocalHeartbeat();
49-
void ValidateAndStore(const std::string &licenseKey);
50-
void ApplySignedResponse(const std::string &responseJson, const std::string &expectedNonce, const std::optional<std::string> &licenseKey, SigningContext context);
51-
52-
std::string PostJson(const std::string &path, const std::string &bodyJson, std::string *usedNonce = nullptr) const;
53-
std::string ExtractServerError(const std::string &responseJson) const;
54-
void Fail(const std::string &reason, const std::exception *exc = nullptr) const noexcept;
55-
56-
std::string GetHwid() const;
57-
std::string SafeMacAddress() const;
58-
std::string SafeCpuInfo() const;
59-
std::string SafeDiskSerial() const;
60-
std::string RunCommand(const std::string &command) const;
61-
62-
static bool ExtractJsonValue(const std::string &json, const std::string &key, JsonValue &outValue);
63-
static std::optional<std::string> ExtractJsonString(const std::string &json, const std::string &key);
64-
static std::optional<long long> ExtractJsonInt(const std::string &json, const std::string &key);
65-
static std::string BuildJsonBody(const std::vector<std::pair<std::string, std::string>> &pairs);
66-
static std::string EscapeJsonString(const std::string &value);
67-
static std::string UnescapeJsonString(const std::string &value, bool &ok);
68-
static std::string Trim(const std::string &value);
69-
static std::string ToLower(std::string value);
70-
71-
static std::string GenerateNonceHex32();
72-
static std::vector<unsigned char> Sha256Bytes(const std::string &input);
73-
static std::string Sha256Hex(const std::string &input);
74-
static std::string BytesToHexLower(const std::vector<unsigned char> &bytes);
75-
static std::vector<unsigned char> DecodeBase64Any(const std::string &value);
76-
static std::vector<unsigned char> DecodeBase64WithAlphabet(const std::string &value, bool urlSafe);
77-
static std::string AddBase64Padding(const std::string &value);
78-
static bool IsSuccessStatus(const JsonValue &status);
79-
static std::optional<long long> ExtractExpiresInFromSessionToken(const std::string &sessionToken);
80-
static std::optional<std::string> DecodeSessionTokenBody(const std::string &sessionToken);
81-
void VerifySignature(const std::string &rawPayloadB64, const std::string &signature) const;
82-
83-
std::string appId_;
84-
std::string appSecret_;
85-
std::string publicKey_;
86-
std::string heartbeatMode_;
87-
int heartbeatInterval_;
88-
std::string apiBaseUrl_;
89-
std::function<void(const std::string &, const std::exception *)> onFailure_;
90-
int requestTimeout_;
91-
92-
mutable std::mutex lock_;
93-
bool heartbeatStarted_;
94-
95-
std::string licenseKey_;
96-
std::string sessionToken_;
97-
std::optional<long long> sessionExpiresIn_;
98-
std::string lastNonce_;
99-
std::string rawPayloadB64_;
100-
std::string signature_;
101-
std::string keyId_;
102-
std::vector<unsigned char> verifyPublicKeyBytes_;
103-
std::string sessionDataJson_;
104-
std::string appVariablesJson_;
105-
std::string licenseVariablesJson_;
106-
bool authenticated_ = false;
107-
bool heartbeatStop_ = false;
108-
std::string hwid_;
109-
std::unordered_set<std::string> knownServerErrors_ = {
110-
"invalid_app",
111-
"invalid_key",
112-
"expired",
113-
"revoked",
114-
"hwid_mismatch",
115-
"no_credits",
116-
"app_burn_cap_reached",
117-
"blocked",
118-
"rate_limited",
119-
"replay_detected",
120-
"app_disabled",
121-
"session_expired",
122-
"bad_request",
123-
"system_error",
124-
};
125-
};
126-
127-
} // namespace authforge
1+
#pragma once
2+
3+
#include <cstdint>
4+
#include <exception>
5+
#include <functional>
6+
#include <mutex>
7+
#include <optional>
8+
#include <unordered_set>
9+
#include <string>
10+
#include <utility>
11+
#include <vector>
12+
13+
namespace authforge {
14+
15+
class AuthForgeClient {
16+
public:
17+
static constexpr const char *kDefaultApiBaseUrl = "https://auth.authforge.cc";
18+
19+
AuthForgeClient(
20+
std::string appId,
21+
std::string appSecret,
22+
std::string publicKey,
23+
std::string heartbeatMode,
24+
int heartbeatInterval = 900,
25+
std::string apiBaseUrl = kDefaultApiBaseUrl,
26+
std::function<void(const std::string &, const std::exception *)> onFailure = nullptr,
27+
int requestTimeout = 15,
28+
int ttlSeconds = 0);
29+
30+
bool Login(const std::string &licenseKey);
31+
void Logout();
32+
bool IsAuthenticated() const;
33+
std::optional<std::string> GetSessionDataJson() const;
34+
std::optional<std::string> GetAppVariablesJson() const;
35+
std::optional<std::string> GetLicenseVariablesJson() const;
36+
37+
private:
38+
struct JsonValue {
39+
bool exists = false;
40+
bool isString = false;
41+
std::string value;
42+
};
43+
44+
enum class SigningContext { Validate, Heartbeat };
45+
46+
void StartHeartbeatOnce();
47+
void HeartbeatLoop() noexcept;
48+
void ServerHeartbeat();
49+
void LocalHeartbeat();
50+
void ValidateAndStore(const std::string &licenseKey);
51+
void ApplySignedResponse(const std::string &responseJson, const std::string &expectedNonce, const std::optional<std::string> &licenseKey, SigningContext context);
52+
53+
std::string PostJson(const std::string &path, const std::string &bodyJson, std::string *usedNonce = nullptr) const;
54+
std::string ExtractServerError(const std::string &responseJson) const;
55+
void Fail(const std::string &reason, const std::exception *exc = nullptr) const noexcept;
56+
57+
std::string GetHwid() const;
58+
std::string SafeMacAddress() const;
59+
std::string SafeCpuInfo() const;
60+
std::string SafeDiskSerial() const;
61+
std::string RunCommand(const std::string &command) const;
62+
63+
static bool ExtractJsonValue(const std::string &json, const std::string &key, JsonValue &outValue);
64+
static std::optional<std::string> ExtractJsonString(const std::string &json, const std::string &key);
65+
static std::optional<long long> ExtractJsonInt(const std::string &json, const std::string &key);
66+
static std::string BuildJsonBody(const std::vector<std::pair<std::string, std::string>> &pairs);
67+
static std::string EscapeJsonString(const std::string &value);
68+
static std::string UnescapeJsonString(const std::string &value, bool &ok);
69+
static std::string Trim(const std::string &value);
70+
static std::string ToLower(std::string value);
71+
72+
static std::string GenerateNonceHex32();
73+
static std::vector<unsigned char> Sha256Bytes(const std::string &input);
74+
static std::string Sha256Hex(const std::string &input);
75+
static std::string BytesToHexLower(const std::vector<unsigned char> &bytes);
76+
static std::vector<unsigned char> DecodeBase64Any(const std::string &value);
77+
static std::vector<unsigned char> DecodeBase64WithAlphabet(const std::string &value, bool urlSafe);
78+
static std::string AddBase64Padding(const std::string &value);
79+
static bool IsSuccessStatus(const JsonValue &status);
80+
static std::optional<long long> ExtractExpiresInFromSessionToken(const std::string &sessionToken);
81+
static std::optional<std::string> DecodeSessionTokenBody(const std::string &sessionToken);
82+
void VerifySignature(const std::string &rawPayloadB64, const std::string &signature) const;
83+
84+
std::string appId_;
85+
std::string appSecret_;
86+
std::string publicKey_;
87+
std::string heartbeatMode_;
88+
int heartbeatInterval_;
89+
std::string apiBaseUrl_;
90+
std::function<void(const std::string &, const std::exception *)> onFailure_;
91+
int requestTimeout_;
92+
// Requested session token lifetime in seconds for /auth/validate. 0 means
93+
// "let the server pick its default" (24h today). Server clamps to
94+
// [3600, 604800]; preserved across heartbeat refreshes.
95+
int ttlSeconds_;
96+
97+
mutable std::mutex lock_;
98+
bool heartbeatStarted_;
99+
100+
std::string licenseKey_;
101+
std::string sessionToken_;
102+
std::optional<long long> sessionExpiresIn_;
103+
std::string lastNonce_;
104+
std::string rawPayloadB64_;
105+
std::string signature_;
106+
std::string keyId_;
107+
std::vector<unsigned char> verifyPublicKeyBytes_;
108+
std::string sessionDataJson_;
109+
std::string appVariablesJson_;
110+
std::string licenseVariablesJson_;
111+
bool authenticated_ = false;
112+
bool heartbeatStop_ = false;
113+
std::string hwid_;
114+
std::unordered_set<std::string> knownServerErrors_ = {
115+
"invalid_app",
116+
"invalid_key",
117+
"expired",
118+
"revoked",
119+
"hwid_mismatch",
120+
"no_credits",
121+
"app_burn_cap_reached",
122+
"blocked",
123+
"rate_limited",
124+
"replay_detected",
125+
"app_disabled",
126+
"session_expired",
127+
"bad_request",
128+
"system_error",
129+
};
130+
};
131+
132+
} // namespace authforge

0 commit comments

Comments
 (0)