@starasia/license-client
v0.6.0
Published
Backend Express middleware for enforcing a central organization-owned license (license-api) — forwards the caller's verified SSO bearer token, never a separate app secret.
Readme
@starasia/license-client
Backend Express middleware for enforcing a central organization-owned license
(license-api) in any Starasia app. Mirrors the role @starasia/sso-api plays for
authentication — add a few lines of config, not a custom integration per app.
What it does
- Resolves which license your organization has for your app, and whether the calling
user is allowed to consume it — by forwarding their already-verified SSO bearer
token to
license-api. No separate per-app secret or service credential. - Manages a small signed,
HttpOnlycookie holding the device identity (and, once acquired, the active session) — self-contained, no session-store dependency. - Forwards the end user's User-Agent and IP (
ctx.userAgent/ctx.ip; the guard reads theUser-Agentheader and yourclientIpoption) on activate and acquire, so the license console can label devices. - Fails closed on every denial and on any upstream outage (403/409/503, never a silent bypass).
What it does not do (by design, for now)
- No service-to-service credential — matches the current approved design of
license-api's organization-owned model. If that changes, this package's auth story changes with it. - No application/organization picker UI, no license purchase/allocation admin — those
live in the platform console (
admin-dashboard), not in a consuming app's backend. - No built-in SSE/WebSocket wrapper —
assertStillValid()is the primitive; wire it into your own stream loop (see below), since stream shapes differ per app/runtime.
Install
Not yet published. Point your app's package.json at this checkout (e.g. a file:
dependency) until a decision is made to publish it to the org's private registry.
Quick start
import express from 'express'
import { createLicenseGuard } from '@starasia/license-client'
const guard = createLicenseGuard({
application: ssoClientId, // this app's sso-api client_id; no env vars needed
getBearerToken: req => req.header('authorization')?.replace(/^Bearer /, ''),
getOrganizationId: req => req.params.organizationId, // however your app resolves this
getPlatform: req => req.header('x-your-app-platform') === 'electron' ? 'electron' : 'web',
})
const app = express()
app.use('/api/v1/organizations/:organizationId', guard.middleware) // protect everything under here
app.post('/api/v1/license/heartbeat', guard.heartbeat) // call this on an interval from your frontend
app.post('/api/v1/auth/logout', guard.release, (req, res) => res.sendStatus(204))Inside a protected handler, req.license is { licenseId, sessionId, expiresAt }.
Long-lived connections (SSE/WebSocket)
app.get('/api/v1/organizations/:organizationId/stream', guard.middleware, async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
const interval = setInterval(() => {
guard.assertStillValid(req).then(
() => res.write(`data: ping\n\n`),
() => { clearInterval(interval); res.end() }, // denied or license-api unavailable
)
}, 15000)
req.on('close', () => clearInterval(interval))
})Not using Express? Use the framework-agnostic core directly
createLicenseGuard is a thin Express adapter over createLicenseCore, which takes and
returns plain strings — no Express/Fetch-API-specific types — so it works in a raw
http.createServer handler, a Fetch API (Request/Response) style app, Next.js, etc.
import { createLicenseCore } from '@starasia/license-client'
const core = createLicenseCore({ application: ssoClientId })
// Inside your own request handler:
const { value: license, setCookie } = await core.check({
token: theCallersVerifiedAccessToken,
organizationId: theOrganizationIdForThisRequest,
platform: 'web', // or 'electron' — whichever client is calling right now
cookieHeader: request.headers.get('cookie'),
ip: serverObservedIp,
userAgent: request.headers.get('user-agent'),
})
// `license` is { licenseId, sessionId, expiresAt }, or { unlicensed: true } / { degraded: true }. If `setCookie` is present, add it as a
// Set-Cookie header on your response (the device/session identity changed).core.heartbeat(ctx), core.release(ctx), and core.assertStillValid(ctx) mirror the guard's
equivalents, same ctx shape, same LicenseClientError codes.
Turning licensing on and off
No environment variables are needed. Register the application (pick it from the sso-api dropdown) and keep it enabled in the license console: from then on every organization whose admin grants a user that application's role gets a license automatically. Disable the application to stop enforcing. Unregistered or disabled applications pass every check.
License key before login (device activation)
When the application has Wajib aktivasi (requireActivation) on in the license
console, every installation must be activated with a user's license key before it can be
used. The key is entered before login:
app.use(express.json())
// Before your auth middleware: nobody is logged in yet.
app.get('/license/device', guard.deviceStatus) // { activated, organizationId?, degraded? }
app.post('/license/device/activate', guard.activateDevice) // body { licenseKey }- On start, call
GET /license/device. Whileactivatedisfalse, show a license-key screen (formatPREFIX-XXXX-XXXX-XXXX) instead of the login screen. POST /license/device/activate. Errors:LICENSE_KEY_INVALID,DEVICE_SLOTS_FULL(every slot of that key's owner is taken; an organization admin can release one in workspaces),TOO_MANY_ATTEMPTS(10 tries per client IP per 10 minutes).- Then log in as usual. The guard middleware refuses
ACTIVATION_REQUIREDwhile the installation isn't activated.
The key tells license-api whose license the device holds; login tells it who is using it. Another user with access of their own may use the device, but it is recorded and the key is flagged as shared in workspaces. A user without access is refused.
Configuration reference
| Option | Required | Notes |
| --- | --- | --- |
| application | yes | Your app's sso-api client_id (or its key in the license console). Licensing is on only while the application is registered and enabled in the license console; otherwise every call passes (req.license = { unlicensed: true }). |
| apiUrl | no | Default https://api.starasia.tech/license-service. Override for local dev (http://127.0.0.1/localhost allowed) or tests. |
| cookieSecret | no | HMAC key for the device cookie. Default is derived from application; license-api verifies the device key itself, so this only rejects hand-edited cookies early. Rotating it makes every installation enter its key again. |
| getBearerToken | yes | Return the caller's verified SSO access token, or undefined/null. |
| getOrganizationId | yes | Return the customer organization context for this request. |
| getPlatform | yes | Return 'web' or 'electron' for this request — a single deployment commonly serves both. |
| getApplicationVersion | no | Forwarded as audit metadata only — never trusted for authorization. |
| clientIp | no | Server-observed IP only; never read this from a client-supplied header yourself. |
| cookieName | no | Default sa_license_device. |
| cookieSecure | no | Default true. Only set false for plain-http://localhost development. |
| failOpen | no | Default true: while license-api is unreachable (network, timeout, 5xx), requests pass with req.license = { degraded: true } and deviceStatus reports activated: true, degraded: true. Explicit denials are always enforced. |
| cookieMaxAgeSeconds | no | Default 180 days — this is the device-identity cookie's lifetime, not the license session TTL (that's configured on license-api itself via LEASE_TTL_MS). |
Error handling
Every rejected request throws/returns a LicenseClientError with a stable .code
(UNAUTHORIZED, NO_ORGANIZATION_CONTEXT, LICENSE_NOT_FOUND, ALLOCATION_REQUIRED,
ALLOCATION_REVOKED, LICENSE_REVOKED, LICENSE_EXPIRED, ACTIVATION_REQUIRED,
USER_DEVICE_LIMIT_REACHED, DEVICE_BINDING_MISMATCH, SESSION_NOT_ACTIVE,
LICENSE_KEY_INVALID, DEVICE_SLOTS_FULL, TOO_MANY_ATTEMPTS, INVALID_REQUEST,
LICENSE_UNAVAILABLE) and matching HTTP status. Map these to your own UI copy the same
way license-key-prelogin's admin console does (explainOrganizationError) — this
package deliberately does not ship UI strings, since wording belongs to each product.
Local verification
npm install
npm run typecheck
npm test
npm run buildTests mock fetch against a tiny in-memory fake of license-api's consumption
endpoints — no real network or database involved.
