@lessly/users
v0.1.0
Published
Server SDK for the Lessly Users toolkit — local JWT verification, code exchange, refresh and middleware
Downloads
96
Readme
@lessly/users
The server SDK for the Lessly Users toolkit. It runs on a Product's backend, holds the
product server key (usk_…), and exists so that adopting Lessly Users never requires
hand-rolling JWT handling.
import { createUsersClient } from '@lessly/users';
const users = createUsersClient({
productId: process.env.LESSLY_PRODUCT_ID!,
serverKey: process.env.LESSLY_USERS_SERVER_KEY!, // usk_… — never ships to a browser
});Environments
The default base URL is production: https://public.lessly.com, from which the SDK derives
https://public.lessly.com/{productId}/users for both the API and the issuer it pins.
Keys and environments do not mix. A key minted against the production console works only
against public.lessly.com; a key from the dev console only against the dev edge. A mismatch is
indistinguishable from an unknown key on the wire, so every 401 the SDK raises carries a factual
hint naming the URL it actually called.
const users = createUsersClient({
productId,
serverKey,
baseUrl: 'https://public.lessly.dev', // a different edge
// or, for a local stack that is not behind an edge at all:
apiUrl: 'http://localhost:3100',
issuer: `http://localhost:3100/${productId}/users`,
headers: { 'x-gateway-product-id': productId, 'x-gateway-public-endpoint': 'true' },
});verifyToken(jwt, { checkRevoked })
Local verification against the product's published JWKS — no call to us on your hot path. It implements the toolkit's verification contract exactly:
issmust equal the configured issuer; it is never derived from the token.jku,x5uand an embeddedjwkheader are ignored.kidis resolved only within the configured product's key set.ES256only;audrequired and equal toproductId; ±60s clock skew.- JWKS cached for ≤10 minutes, refreshed on an unknown
kid(with a cooldown, so forgedkids cannot turn into a request flood).
const claims = await users.verifyToken(accessToken);
claims.sub; // end user id
claims.sid; // session id
claims.isImpersonated; // true while an operator is impersonating this user{ checkRevoked: true } additionally asks /sessions/introspect, which answers off Postgres —
the only way to see a revocation before the ≤10 minute access TTL runs out. It throws
SessionRevokedError.
Errors are one family: UsersError with code, plus TokenExpiredError, TokenInvalidError,
SessionRevokedError, AuthenticationError, RateLimitedError (retryAfter in seconds),
CodeExchangeError, RefreshError, UsersApiError.
exchangeCode(code, codeVerifier, { redirectUri })
The backend half of the hardened code handoff: your callback receives a one-time code by form
POST, and this exchanges it — with the PKCE verifier, the exact callback and your server key —
for { accessToken, refreshToken, expiresIn, session }.
refresh(refreshToken)
Rotation. Concurrent calls with the same token are coalesced into a single request, so an
SSR page that wakes ten handlers at once performs one rotation. 429s surface as RateLimitedError
with retryAfter.
Middleware
import { expressMiddleware, requireAuth } from '@lessly/users';
app.use('/api', expressMiddleware(users)); // sets req.auth
app.use('/admin', expressMiddleware(users, { checkRevoked: true }));
// or framework-agnostic:
const guard = requireAuth(users, (ctx) => ctx.cookies.session);
const claims = await guard(ctx);express is not a dependency — the adapter is typed structurally, so it works with anything
Express-shaped.
