Skip to content

Latest commit

 

History

History
262 lines (198 loc) · 22.3 KB

AGENTS.md

File metadata and controls

262 lines (198 loc) · 22.3 KB

AuthForge SDK: AI Agent Reference

This file is optimized for AI coding agents (Cursor, Copilot, Claude Code, etc.). It contains everything needed to correctly integrate AuthForge licensing into a project.

What AuthForge does

AuthForge is a license key validation service. Your app activates by sending a license key + hardware ID to POST /auth/validate; the server checks revocation, expiry, HWID binding, and credits, then returns an Ed25519-signed session with a TTL. By default the app then runs through the grace period: it keeps running on the signed session with no network calls, and a background check fails when the session TTL expires. Optionally, enable online check-ins (online_heartbeat=True): periodic POST /auth/heartbeat calls for fast revocation and concurrent-use detection. If the license is revoked or expired, the check-in fails and you handle it (typically exit the app).

There is also a separate mode for machines that can never reach the internet: offline license files (.authforge). The operator mints a signed file in the AuthForge cloud; login_from_file() verifies it locally with the app public key and the machine HWID, with zero network calls. Do not ship the App Secret in those builds (app_secret=None). Only use it when the user explicitly asks for air-gapped / offline-file licensing. The default integration is always online login() + grace period. To collect the HWID for a bound file, write an activation request (.authforge-request) with create_activation_request / write_activation_request. It is not a license, is not signed, and does not mint anything. Prefer it over printing the raw HWID.

A third, separate mode is self-serve offline activation: activate_offline(license_key, path=...) makes one online call (POST /auth/offline/activate) that turns the license key into a lifetime .authforge file bound to this machine, verifies it locally, writes it to path and authenticates the client exactly like login_from_file(). Later launches use login_from_file() with no network. Unlike air-gapped files (which still ship with app_secret=None), this mode needs the App Secret because it is an online call. The app owner must opt the app in, and only perpetual, seat-limited licenses qualify. Use it only when the user wants one-time activation followed by permanent offline use (for example a one-time-purchase desktop app); a refund or chargeback revokes the license online but cannot reach files already issued.

Billing model (so you can pick sensible intervals)

  • 1 login() or validate_license() = 1 credit (one /auth/validate debit each).
  • 10 online check-ins = 1 credit (billed on every 10th successful /auth/heartbeat per license). Grace period checks are local and free.
  • 1 offline file mint = 1 credit (charged to the operator when the file is minted). login_from_file() / verify_license_file() cost nothing.
  • 1 new offline activation = 2 credits; re-activating the same machine is free (activate_offline(), charged to the app owner; a refused activation costs nothing).
  • Keep heartbeat_interval at >= 10 seconds (900 / 15 min is the typical desktop default). /auth/heartbeat is limited to 6 requests/minute per license key, and revocations still take effect on the next check-in.

Installation

Prefer pip install authforge-sdk from PyPI (installs the cryptography dependency). Imports remain from authforge import .... For a vendored single-file layout, copy authforge.py and add cryptography to your environment. Requires Python 3.9+.

Minimal working integration

import sys
import threading
from typing import Optional

from authforge import AuthForgeClient, AuthForgeError

license_lost = threading.Event()


def on_failure(reason: str, exc: Optional[Exception]) -> None:
    if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
        return  # network blip / rate_limited: the SDK checks in again next interval
    print(f"AuthForge: {reason}", file=sys.stderr)
    if exc is not None:
        print(exc, file=sys.stderr)
    # on_failure can run on the heartbeat thread: signal the main thread instead of exiting here.
    license_lost.set()


def main() -> None:
    client = AuthForgeClient(
        app_id="YOUR_APP_ID",
        app_secret="YOUR_APP_SECRET",
        public_key="YOUR_PUBLIC_KEY",  # required: base64 Ed25519 key from the dashboard
        on_failure=on_failure,
    )
    license_key = input("Enter license key: ").strip()
    if not client.login(license_key):
        print("Login failed.", file=sys.stderr)
        sys.exit(1)
    # --- Your application code starts here ---
    print("Running with a valid license.")
    while not license_lost.is_set():
        license_lost.wait(1.0)  # replace with short units of work that check license_lost between them
    # --- Your application code ends here ---
    # Save the user's work here, then stop.
    client.logout()
    sys.exit(1)


if __name__ == "__main__":
    main()

This activates once and then runs through the grace period (no network) until the session TTL expires. To detect revocations quickly or catch concurrent use, add online_heartbeat=True (and optionally tune heartbeat_interval).

Constructor parameters

Parameter Type Required Default Description
app_id str yes n/a Application ID
app_secret str | None for online APIs n/a Application secret. Required for login / validate_license / self_ban. Pass None or "" for login_from_file only; do not ship it in air-gapped binaries.
public_key str | Sequence[str] yes n/a Base64 Ed25519 public key from the dashboard. Accepts one key, a list, or a comma-separated string; the SDK trusts a signature matching any entry (key rotation)
heartbeat_mode str | None no None Deprecated shim: "LOCAL" maps to the default (grace period), "SERVER" maps to online_heartbeat=True. Emits a DeprecationWarning when provided (case-insensitive)
heartbeat_interval int no 900 Seconds between background checks (minimum 10); applies to grace period checks and online check-ins
api_base_url str no https://auth.authforge.cc API base URL
on_failure Callable[[str, Optional[Exception]], None] | None no None Called on activation/check-in/network failure (not used by validate_license, activate_offline or self_ban, which report failures without it). If omitted, a transient background check failure prints a one-line warning to stderr and check-ins continue; any other failure exits via os._exit(1)
request_timeout int no 15 HTTP timeout (seconds)
ttl_seconds int | None no None (server default: 86400) The grace period duration: how long the app keeps running on the signed session without contacting AuthForge. Server clamps to [3600, 604800] (1h to 7d); preserved across check-in refreshes.
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 deriving the HWID from the OS machine id.
online_heartbeat bool (keyword-only) no False Enable online check-ins: periodic POST /auth/heartbeat for fast revocation and concurrent-use detection

For Telegram/Discord bot flows, prefer immutable IDs (tg:<user_id>, discord:<user_id>) instead of usernames.

Migrating from heartbeat_mode

Earlier releases required heartbeat_mode="LOCAL" or "SERVER" as the 4th argument. It is now optional and deprecated (still accepted, but emits a DeprecationWarning):

  • heartbeat_mode="LOCAL": remove the argument; the grace period is the default.
  • heartbeat_mode="SERVER": replace with online_heartbeat=True.

The attribute client.heartbeat_mode still exists for back-compat and reflects the effective policy ("SERVER" when online check-ins are enabled, "LOCAL" otherwise).

Methods

Method Returns Description
login(license_key: str) bool Activates: validates the license online, verifies signatures, starts the background check thread
validate_license(license_key: str) ValidateLicenseResult Same validate + signatures as login; no session persistence or background checks; never calls on_failure or os._exit
login_from_file(path_or_text: str) bool Offline mode: verifies a .authforge file locally (no network), authenticates the client, never starts background checks. Failures -> on_failure("offline_login_failed", exc) + False; never os._exit
activate_offline(license_key, path=None) bool Self-serve offline activation: one online call -> lifetime HWID-bound .authforge file, verified locally before an atomic write to path, then applied like login_from_file. Needs the App Secret. Returns True; raises AuthForgeError on any failure; never calls on_failure or os._exit
self_ban(*, license_key=None, session_token=None, revoke_license=True, blacklist_hwid=True, blacklist_ip=True) dict /auth/selfban: blacklists HWID/IP, optionally revokes (session mode only). Raises ValueError on any failure, a network failure as AuthForgeError network_error / timeout; never calls on_failure or os._exit
verify_license_file(path_or_text, *, now=None) VerifyLicenseFileResult Same offline checks without changing client state
get_offline_license() dict | None jti, expires_at, hwid_policy, … of the offline file in use; after activate_offline() also replay (True when the server returned a file this machine already had)
get_session_kind() "online" | "offline" | None Kind of session the client holds; None when logged out
get_hwid() str HWID this client sends; the customer reports it so the operator can mint a bound file
create_activation_request(**kwargs) str Unsigned .authforge-request for this machine. No network, no secret, callable before login(). Hostname omitted unless include_machine_name=True
write_activation_request(path, **kwargs) None Writes that file as UTF-8
logout() None Stops background checks and clears session state
is_authenticated() bool Whether a session token is present and marked authenticated
get_session_data() dict | None Decoded signed payload map
get_app_variables() dict | None App-scoped variables
get_license_variables() dict | None License-scoped variables

Error codes the server can return

Full set (KNOWN_SERVER_ERRORS): 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, offline_activation_disabled, offline_activation_requires_perpetual, offline_activation_requires_seats, offline_activation_limit_reached, plus the SDK code offline_file_rejected. Unrecognized codes are passed through verbatim.

Notes:

  • replay_detected is validate-only. rate_limited can be returned by /auth/validate and /auth/heartbeat (heartbeat is license-limited at 6/min and has no app-layer IP limit).
  • /auth/heartbeat returns hwid_mismatch when the HWID is no longer bound to the license (for example after an HWID reset) and blocked when the HWID/IP is blacklisted or not whitelisted.
  • app_burn_cap_reached means the app's configured credit burn cap is hit; revoke_requires_session means a pre-session self-ban tried to revoke a license (only session-authenticated self-ban can revoke).
  • session_expired is also raised locally when the grace period (session TTL) runs out.

Heartbeat failure classification (AuthForgeError.transient / is_transient_error()):

  • Fatal (DEFINITIVE_ERROR_CODES, the only allowlist): revoked, expired, hwid_mismatch, blocked, session_expired, malformed_request, app_disabled, invalid_app, signature_mismatch, plus the activate_offline() codes offline_activation_disabled, offline_activation_requires_perpetual, offline_activation_requires_seats, offline_activation_limit_reached and offline_file_rejected. The SDK clears the stored session (as logout()) and stops check-ins before calling on_failure.
  • Transient: everything else, including network_error, timeout, rate_limited, system_error, no_credits, demo_quota_exceeded, app_burn_cap_reached, bad_request, invalid_key, every http_error_<status>, unexpected_response and unknown codes. The session is kept and the SDK checks in again next interval; after the session TTL passes, the next transient failure becomes a fatal session_expired.
  • A failed check-in is a verdict only if the body is a JSON object with "status": "failed" and a non-empty string error; any other failure body becomes unexpected_response (message includes the raw status/error).
  • rate_limited (or a 429 with no code) is retried after 2s then 5s; the 429 codes no_credits, demo_quota_exceeded, app_burn_cap_reached are not retried immediately.
  • on_failure runs on the heartbeat thread with no SDK lock held: calling logout(), is_authenticated() or login() from it is safe. An in-flight check-in never restores a session after logout()/login().

Common patterns

Reading license variables (feature gating)

vars_map = client.get_license_variables() or {}
tier = vars_map.get("tier")

Graceful shutdown

client.logout()

Offline license file (air-gapped machine, only when asked)

# Step 1 (customer machine): print the HWID so the operator can bind the file to it.
print(client.get_hwid())

# Step 2 (operator): mint the .authforge file in the dashboard or via
# POST /v1/licenses/{licenseKey}/offline-files and deliver it out-of-band.

# Step 3 (customer machine): authorize with the file. No network, no check-ins.
if not client.login_from_file("license.authforge"):
    # on_failure already received ("offline_login_failed", ValueError(code)) where code is one of
    # bad_armor | bad_signature | unsupported_version | malformed_payload | wrong_app | expired | hwid_mismatch
    sys.exit(1)

Offline file error codes (in check order): bad_armor, bad_signature, unsupported_version, malformed_payload, wrong_app, expired, hwid_mismatch.

Self-serve offline activation (one online call, then offline forever; only when asked)

import os
import sys

from authforge import AuthForgeClient, AuthForgeError

LICENSE_PATH = os.path.join(os.path.expanduser("~"), ".myapp", "license.authforge")

client = AuthForgeClient(
    app_id="YOUR_APP_ID",
    app_secret="YOUR_APP_SECRET",  # REQUIRED here: activate_offline is an online call
    public_key="YOUR_PUBLIC_KEY",
    on_failure=lambda reason, exc: None,  # receives ("offline_login_failed", exc) from login_from_file
)

if not (os.path.exists(LICENSE_PATH) and client.login_from_file(LICENSE_PATH)):
    # No file yet, or it failed (e.g. hwid_mismatch after a hardware change): activate once.
    key = input("License key: ").strip()
    try:
        client.activate_offline(key, path=LICENSE_PATH)  # verifies, writes atomically, authenticates
    except AuthForgeError as exc:
        if exc.code == "offline_activation_limit_reached":
            print("This license is used on its maximum number of machines. Contact the vendor.")
        elif exc.transient:
            print(f"Could not activate right now ({exc.code}); try again.")
        else:
            print(f"Activation failed: {exc.code}")
        sys.exit(1)
  • Returns True; raises AuthForgeError on any failure. Missing App Secret raises the usual ValueError("app_secret is required ...") before any request.
  • On failure nothing is written to path (an existing file is left untouched), the call does not authenticate the client (an existing session is kept, not logged out), and on_failure / os._exit are never called.
  • Codes: offline_activation_disabled (app not opted in), offline_activation_requires_perpetual, offline_activation_requires_seats (shared/unlimited keys cannot self-serve), offline_activation_limit_reached (machine cap used; HWID resets do not free it; tell the user to contact the vendor), server hwid_mismatch (no free seat), offline_file_rejected (SDK: the returned file failed local verification; the verifier's reason is in str(exc)), file_write_failed (transient). Existing codes (invalid_key, revoked, no_credits, rate_limited, ...) keep their meaning and classification.
  • Billing: 1 new offline activation = 2 credits (app owner); re-activating the same machine is free and returns the identical file.

Custom error handling

Server error codes appear as AuthForgeError (a ValueError subclass) in the exc passed to on_failure: exc.code is the code (e.g. invalid_key, revoked, hwid_mismatch), str(exc) the message. Reasons are login_failed, heartbeat_failed, or offline_login_failed; network failures arrive once as login_failed or heartbeat_failed with exc.code network_error or timeout. activate_offline() and self_ban() never call on_failure: they raise, a network failure as AuthForgeError with that same code. For heartbeat_failed, exc is always an AuthForgeError; use exc.transient to tolerate outages.

import sys
import threading
from typing import Optional

from authforge import AuthForgeError

license_lost = threading.Event()  # the main loop checks this, saves work, then exits

def on_failure(reason: str, exc: Optional[Exception]) -> None:
    if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
        return  # network / rate_limited / no_credits / unexpected_response: SDK retries next interval
    code = exc.code if isinstance(exc, AuthForgeError) else exc
    if code in {"invalid_key", "expired", "revoked", "hwid_mismatch", "blocked"}:
        print(f"License issue: {code}", file=sys.stderr)
    license_lost.set()  # sys.exit() on the heartbeat thread would only end that thread

If the main thread blocks instead of looping, _thread.interrupt_main() from the callback raises KeyboardInterrupt there so try / finally cleanup runs. os._exit(1) inside the callback is a last resort: it skips all cleanup, so save the user's work first.

Do NOT

  • Do not hardcode the app secret as a plain string literal in source: use environment variables or encrypted config
  • Do not embed the App Secret in air-gapped / login_from_file builds: pass None or ""; verification only needs app id + public key. (Self-serve activate_offline() builds are the exception: that call is online and needs the secret.)
  • Do not call activate_offline() on every launch: try login_from_file(path) first and activate only when there is no valid file
  • Do not confuse the server's hwid_mismatch from activate_offline() (no free seat) with offline_file_rejected (the returned file failed local verification)
  • Do not skip the on_failure callback: without it, transient check-in failures only print a stderr warning, but definitive ones (and a failed login()) terminate the process via os._exit(1) without your cleanup
  • Do not call os._exit() from on_failure as the normal shutdown path: signal the main thread (a threading.Event, or _thread.interrupt_main()) so it can save work and exit cleanly
  • Do not treat every heartbeat_failed as a network blip or every one as fatal: check exc.transient. Fatal failures have already cleared the session, so do not keep the app running on it
  • Do not call login() on every app action: call it once at startup; the grace period (or online check-ins) handles the rest
  • Do not pass heartbeat_mode in new code: it is deprecated. Use the default grace period, or online_heartbeat=True when you need fast revocation or concurrent-use detection
  • Do not treat the grace period as persistent offline licensing: it is session continuation after one successful online activation, and revocations are only picked up at the next online validate or check-in
  • Do not reach for login_from_file() unless the user explicitly needs air-gapped / offline-file licensing: the default is online login() + grace period
  • Do not expect an online revoke to disable an offline file that is already on a customer machine: the file stays valid until its own expiresAt; prefer short expiries and HWID-bound files
  • Do not mint or accept hwid.mode: "any" files casually: anyone who copies an unbound file has a working license
  • Do not call login_from_file() with another app's public key or app id: the file is rejected with bad_signature / wrong_app by design
  • Do not try to build .authforge files client-side: only the AuthForge cloud holds the signing key; there is no BYO issuer
  • Do not call self_ban() or any other online method after login_from_file(): an offline session has no server session (get_session_kind() is "offline"), so self_ban() raises ValueError("offline_session") without contacting the server and online check-ins never start; machines that can reach AuthForge should use online login()
  • Do not bind an offline file to an HWID collected before upgrading to 2.0: HWID v2 replaced the old fingerprint, so pre-2.0 HWIDs no longer match; collect it again with create_activation_request() (since 2.0 any AuthForge SDK gives the same HWID for the same app id and machine)

HWID v2

The HWID is sha256("authforge-hwid-v2|" + app_id + "|" + machine_id) (lowercase hex), where machine_id is the trimmed, lowercased OS machine id: macOS IOPlatformUUID, Linux /etc/machine-id (then /var/lib/dbus/machine-id), Windows MachineGuid (64-bit registry view). Without a valid OS id the SDK falls back to legacy:<pre-2.0 fingerprint>. For containers, use hwid_override.

hwid_vectors.json is generated by authforge-node/generate_hwid_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos in the same change.

Activation request vectors

activation_request_vectors.json is generated by authforge-node/generate_activation_request_vectors.mjs. Regenerating it means copying the file unmodified into all six SDK repos and platform/frontend/src/test/fixtures/activation_request_vectors.json in the same change. The vector sdk value is a frozen encoding fixture, not the live SDK tag.