@sewdn/signed-license
v0.1.1
Published
Ed25519-signed token format for offline licenses and passwordless auth (verify + optional admin signing).
Readme
Signed License SDK (workspace: @signed-license/sdk)
TypeScript library for signed JSON tokens: base64url(payloadJson).base64url(signature). Signing and verification use node:crypto; the algorithm is selected per token (see Cryptography below). Ed25519 is the default when the payload omits sig.
- Runtime: Node.js 20+ and Bun (uses
node:cryptofor verify/sign). - Imports: ESM only (
"type": "module").
Names: monorepo vs npm
| Context | Package name | Import / install |
| ---------------- | ----------------------- | ------------------------------------- |
| This monorepo | @signed-license/sdk | import … from "@signed-license/sdk" |
| npm (your scope) | @sewdn/signed-license | npm install @sewdn/signed-license |
Published tarballs are built with bun run build:npm from the repo root (see root README.md). Do not publish the workspace package directly.
Install (registry)
npm install @sewdn/signed-licenseCryptography (TokenSigV1)
The exact UTF-8 JSON of the payload (including optional sig) is what gets signed. Verifiers read sig from the decoded payload (or assume the default) so the token stays self-describing. PEM is always public SPKI + private PKCS#8 (Node’s usual export for these key types).
Default: If sig is omitted, verification behaves as { "kty": "ed25519" } (DEFAULT_TOKEN_SIG_V1). signTokenPayload embeds sig automatically: payload.sig → options.sig → default.
| Preset | sig on the wire (JSON) | Key generation | sign / verify (digest or scheme) |
| ------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Ed25519 (default) | { "kty": "ed25519" } | generateKeyPairSync("ed25519", …) | EdDSA, algorithm: null |
| Ed448 | { "kty": "ed448" } | generateKeyPairSync("ed448", …) | EdDSA, algorithm: null |
| ECDSA (NIST P-curves) | { "kty": "ec", "crv": "P-256" \| "P-384" \| "P-521" } | generateKeyPairSync("ec", { namedCurve: … }) — maps to prime256v1 / secp384r1 / secp521r1 | sha256 / sha384 / sha512 (matched to curve) |
| RSA (PKCS#1 v1.5) | { "kty": "rsa", "bits": 2048 \| 3072 \| 4096, "hash": "sha256" \| "sha384" \| "sha512" } | generateKeyPairSync("rsa", { modulusLength: bits, … }) | RSA-SHA256, RSA-SHA384, or RSA-SHA512 |
API: generateKeyPairPem(sig?) — generate a key pair for a given preset (defaults to Ed25519). parseTokenPayloadJson rejects a payload if sig is present but not a valid TokenSigV1.
Structured presets: TOKEN_SIG_V1_PRESET_ENTRIES (and findTokenSigPresetById, DEFAULT_TOKEN_SIG_PRESET_ID) in the SDK list every supported variant with stable id strings and labels — used by the signed-license CLI and reusable for docs or tooling.
Env keys: SIGNED_LICENSE_PUBLIC_KEY_PEM / SIGNED_LICENSE_PRIVATE_KEY hold PEM material for whichever algorithm you mint with; the verifier must use the matching public key.
Application service (recommended)
Use createSignedLicenseService for verify, mint, and keygen with env-based keys. Cryptographic verification is the same for every token; business rules (e.g. “this app requires sub”, “this CLI only accepts tokens without sub”) belong in verifyAndNarrow or verifySignedTokenAs, not in the service config.
import { createSignedLicenseService } from "@sewdn/signed-license";
const signedLicense = createSignedLicenseService();
// Project-specific payload: createSignedLicenseService<MyPayload>({ ... })
const result = signedLicense.verifyToken(token); // uses SIGNED_LICENSE_PUBLIC_KEY_PEM if no PEM passed
const strict = signedLicense.verifyAndNarrow(token, (json) => myParse(json)); // your policy + domain types
const t = signedLicense.mintToken({
v: 1,
sub: "[email protected]",
scopes: ["a"],
ver: "1",
iat: Math.floor(Date.now() / 1e3),
});Tree-shake by subpath: @sewdn/signed-license/service (createSignedLicenseService), @sewdn/signed-license/admin (signing, keygen, sanitizePemValue — no env), @sewdn/signed-license/env (resolveSignedLicensePrivateKeyPemFromEnv / resolveSignedLicensePublicKeyPemFromEnv from process.env).
Browser / shared parsing: import payload types and parseTokenPayloadJson from @sewdn/signed-license/payload so bundlers do not pull in node:crypto from the main entry.
verifySignedTokenWithRawPayload — use when you must parse the exact signed JSON string with app-specific rules (roles enums, etc.) without splitting the token yourself.
Project-specific payload types
Wire format stays TokenPayloadV1 (or extensions that still serialize as compatible JSON). For typed verification:
TokenPayloadParser<TPayload>—(payloadJson: string) => TPayload | undefined(returnundefinedto reject).verifySignedTokenAs(token, pem, parsePayload)→VerifySignedTokenResult<TPayload>.verifySignedTokenWithRawPayloadAs— same, pluspayloadJsonon success.readTokenPayloadAs— decode only (no signature check); use for previews/debug.createSignedLicenseService<TPayload>()—SignedLicenseService<TPayload>;verifyToken,verifyTokenWithRawPayload,verifyAndNarrow, andmintTokenall use the sameTPayload(defaultTokenPayloadV1).signTokenPayload<TPayload extends TokenPayloadV1>(payload, pem)when minting outside the service.
import {
parseTokenPayloadJson,
verifySignedTokenAs,
type TokenPayloadV1,
} from "@sewdn/signed-license";
type MyPayload = TokenPayloadV1 & { readonly tenantId: string };
function parseMyPayload(json: string): MyPayload | undefined {
const o = JSON.parse(json) as Record<string, unknown>;
const base = parseTokenPayloadJson(json);
if (!base || typeof o.tenantId !== "string") return undefined;
return { ...base, tenantId: o.tenantId };
}
const out = verifySignedTokenAs(token, publicKeyPem, parseMyPayload);
if (out.ok) out.payload.tenantId;Keeping integrations small (suggestions)
- Prefer one dependency — Depend only on
@sewdn/signed-license; avoid a second “wrapper” package in each monorepo unless it adds real domain types. - Subpath imports — Use
@sewdn/signed-license/service,/admin, or/envso bundlers drop minting or env wiring from builds when possible. - Narrow once — Pass
verifyAndNarrow(..., parseYourPayload)(orverifySignedTokenWithRawPayload+ your parser) for product-specific rules; the shared service does not distinguish “license” vs “auth” modes. - Browser vs Node — Keep Web Crypto verification in the frontend only; use
createSignedLicenseService/verifySignedTokenon the server. A future@sewdn/signed-license/browsersubpath could isolatecrypto.subtlewithout pullingnode:cryptoif needed.
Verification (consumer apps)
import {
verifySignedToken,
verifyWithPublicKeyFromEnv,
type TokenPayloadV1,
} from "@sewdn/signed-license";
const result = verifySignedToken(token, publicKeyPem);
if (result.ok) {
const payload: TokenPayloadV1 = result.payload;
// license-style: optional label/exp
// auth-style: check payload.sub, payload.scopes, etc.
}In this repo, use @signed-license/sdk in imports instead of @sewdn/signed-license.
readTokenPayload(token)— decode JSON from the first segment without verifying the signature (debug/preview only).isExpired(payload)— comparesexpto current time when present.isAuthStylePayload(payload)—truewhensubis present (non-empty).
Set SIGNED_LICENSE_PUBLIC_KEY_PEM at runtime (deployment env, container secret, etc.) to the public PEM that matches your tokens’ signing keys, then use verifyWithPublicKeyFromEnv(token) (returns a result with an error when the key is missing). resolveSignedLicensePublicKeyPemFromEnv() (from @sewdn/signed-license or @sewdn/signed-license/env) returns the PEM string or throws if unset or empty after normalization. Verification uses the Signed License env namespace only (no per-project aliases).
Signing (minting tools only)
@sewdn/signed-license/admin — pure signing and PEM helpers (no process.env):
import { generateKeyPairPem, signTokenPayload } from "@sewdn/signed-license/admin";@sewdn/signed-license/env — read SIGNED_LICENSE_PRIVATE_KEY / SIGNED_LICENSE_PUBLIC_KEY_PEM (throws if unset or empty after sanitizePemValue):
import { resolveSignedLicensePrivateKeyPemFromEnv } from "@sewdn/signed-license/env";
const pem = resolveSignedLicensePrivateKeyPemFromEnv();
const token = signTokenPayload({ v: 1, iat: Math.floor(Date.now() / 1e3) }, pem);Private key for minting: SIGNED_LICENSE_PRIVATE_KEY (PKCS#8 PEM), same namespace as SIGNED_LICENSE_PUBLIC_KEY_PEM for verify. Use a key pair consistent with the sig field you embed when minting (see Cryptography).
Payload shape (TokenPayloadV1)
| Field | License-style (offline / entitlement) | Auth-style (sign-in) |
| -------- | --------------------------------------------------------------- | ---------------------------- |
| v | 1 | 1 |
| ver | optional version label | optional |
| label | optional human-readable note | optional |
| exp | optional (recommended when minting) | optional |
| iat | optional (recommended when minting) | optional |
| sub | omitted | set (subject, e.g. email) |
| scopes | optional string capabilities | optional string capabilities |
| sig | optional TokenSigV1 (algorithm); omit for default Ed25519 | same |
Testing
From packages/sdk, run bun test. Typings for bun:test come from the bun-types dev dependency; the default tsconfig.json includes *.test.ts and lists bun-types so the editor resolves bun:test. Run bun run typecheck for tsc --noEmit on all sources including tests.
Migrating from internal packages
| Old | New |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| @scaffold-v2/svc-license / @procertus-docs/svc-license | @sewdn/signed-license (npm) / @signed-license/sdk (monorepo) |
| verifyLicenseToken | verifySignedToken |
| Earlier docs referring to LicensePayloadV1 / parseLicensePayloadJson | TokenPayloadV1 / parseTokenPayloadJson |
| SIGNET_* env vars (pre–Signed License rename) | SIGNED_LICENSE_* (SIGNED_LICENSE_PRIVATE_KEY, SIGNED_LICENSE_PUBLIC_KEY_PEM) |
| Legacy multi-name private-key env vars | SIGNED_LICENSE_PRIVATE_KEY only |
| SIGNET_EMBEDDED_PUBLIC_KEY_PEM / legacy *_EMBEDDED_* aliases | SIGNED_LICENSE_PUBLIC_KEY_PEM only |
| verifyWithEmbeddedPublicKey / resolveEmbeddedPublicKeyPemFromEnv | verifyWithPublicKeyFromEnv / resolveSignedLicensePublicKeyPemFromEnv |
| resolvePrivateKeyPemFromEnv | resolveSignedLicensePrivateKeyPemFromEnv |
