SDK Login
AppUser) with their username and password, binds the machine to a device, runs every gate, and returns a session token. Unlike /api/v1/activate (which is license-key-first), this route is credential-first — the user signs in, and the server picks the best license attached to their account.
This is for end-users signing into your application, not operators signing into the console.
Request
hwid, os, appVersion, components, and virtual for you — pass only what the method doesn’t already know:
extra covers the rest: client.login(user, pass, { twofa: "123456", hash, label: "Alex's desktop" }).
Parameters
owner, product, hwid, and os are capped at 256 characters. username is trimmed but not length-capped.
Responses
data and the SDK handles both automatically:
offline: { assertion, notAfter, keyId }— a signed offline assertion. The SDK caches it andcheckOffline()can verify it locally against the pinned public key while the server is unreachable. Present when the server has a signing key configured and the login provided an HWID.product.updateAvailable: true, product.latest, product.downloadUrl?— a soft “a newer build exists” hint, sent only when the operator switched autoUpdate on and yourappVersionis behind. The SDK re-emits it as anupdateAvailableevent ({ current, latest, downloadUrl? }). The hard version of the same decision isforceLatest, which refuses outright withUPDATE_REQUIRED.
session is only present when the operator’s plan includes the token system. Without it, the response omits session and the SDK stays session-less.
Behavior
- Product resolution:
owner+productresolve by ID first, then name/slug fallback, scoped to the owner — IDs can’t cross accounts. The owner must beActive, or the pair answersAPP_NOT_FOUND. - Request order: missing fields → product lookup → freshness (if
requireSignedRequests) → lock check → user lookup → password verify → account status → 2FA → clear failures → license selection → gates. - Kill switch:
appDisabledis evaluated inside the gate pipeline (after auth), not before. - One refusal code for bad credentials: unknown user and wrong password both answer
INVALID_PASSWORD. Telling a stranger which half was wrong turns the endpoint into a username oracle. - Two-factor: same module as the panel (
services/otp.services.js). If enabled and notwofasent, responds withTWO_FACTOR_REQUIRED+twoFactorRequired: true. Recovery codes are single-use. - License selection: when an account holds multiple licenses on the product, usable ones (
ACTIVE/TRIAL, unexpired) come first — perpetual keys first, then longest remaining expiry. If none are usable, the server falls back to sorting all of them the same way so the refusal names the real problem. - No license = refusal: an account with zero licenses on this product gets
LICENSE_NOT_FOUND(403). Gate failures (expired, revoked, blacklisted) get their own accurate code fromrunGatesrather than a generic refusal. - Gates also cover: product status (
Maintenance→MAINTENANCE, anything notActive/Beta→APP_NOT_FOUND), platform mismatch, unregistered build hash,forceLatest/minVersion(UPDATE_REQUIRED), HWID/IP/license/username blacklist, geo policies, and version mismatch. TheblockVirtualMachinessetting is stored but not currently enforced by any gate. - Gate failures don’t count as credential failures: the per-account failure counter is cleared once the password verifies, before license lookup and gates run. A paying customer who needs to renew doesn’t get locked out on top of it.
- Session tokens: JWT signed with HS512 carrying
lic(license ID),dev(device ID),pid(product ID),uid(app user ID), andjti(for revocation). Lifetime isLICENSE_TOKEN_TTL(default 900s), heartbeat cadence isHEARTBEAT_WINDOW(default 180s). - Audit trail: logins write
LicenseEventrows (account <user> login). No webhook fires on login. - SDK result:
login()stores the session/license/device/account/subscriptions and emitsloggedIn, thenactivated.client.snapshot()returns{ app, license, product, device, user, account, subscriptions, valid }.
Signed Responses
Every response carries Ed25519 signature headers when a signing key is configured (v2 scheme — the signed input binds the exact request context):
The SDK sends a random
X-Frost-Nonce per request, rejects answers signed more than 60 seconds ago, rejects answers to a different request, and refuses unsigned answers outright — except unsigned 429s from the rate limiter, which it treats as retryable (FrostAuthError with code: "RATE_LIMITED", retryable: true, and retryAfterSeconds from Retry-After). Failed calls throw FrostAuthError carrying the server’s errorCode as code, plus status and requestId. err.needsActivation (404, non-retryable) and err.terminal (non-retryable 4xx) tell the heartbeat when to stop.
When requireSignedRequests is enabled for the product, inbound freshness is enforced before the handler runs: timestamp must be within 60 seconds, nonce must be 16–128 chars and unique (Redis SET NX, 180s TTL). Stale requests get SESSION_EXPIRED, replays get FORBIDDEN, missing timestamp/nonce gets MISSING_FIELDS.
Rate Limits
The per-identity lockout uses the same module as the panel (
services/panel.services.js) but different keys (client:... vs panel:...) — lockouts do not cross between login and panel. The lockout answer embeds the wait in the message (Too many attempts, try again in Ns); the abuse-guard answer carries retryAfterSeconds. The login limiter’s own answer (Too many login attempts) carries no errorCode.
Errors
Operators can reword any message per product — match onerrorCode, never on message text.
