@lessly/users-client
v0.7.0
Published
Browser client for the Lessly Users toolkit — flow engine, PKCE code handoff and session state
Readme
@lessly/users-client
The browser half of the Lessly Users toolkit: a typed flow client, the PKCE code
handoff, and session state. Framework-agnostic and dependency-free. React
bindings live in @lessly/users-react.
It speaks only the public wire contract with a publishable key (upk_…).
It holds no secrets, verifies no tokens, and cannot reach the management plane —
anything this library can do, a curl with the same key can do.
Install
npm install @lessly/users-clientQuickstart — the browser path
import { createUsersClient, isCodeHandoff, isFailure } from '@lessly/users-client';
const users = createUsersClient({
productId: 'prod_123',
publishableKey: 'upk_live_…',
// baseUrl defaults to the production public edge; staging/local pass it explicitly.
});
// One engine: `start` signs in OR signs up, and tells you which only at the end.
const attempt = await users.start({
identifier: email,
redirectUri: 'https://app.example.com/auth/callback', // exactly as allowlisted
});
const result = await attempt.attempt({ strategy: 'password', password });
if (isFailure(result)) {
showError(result.error.code); // 'invalid_credentials' | 'attempt_expired' | …
} else if (isCodeHandoff(result)) {
// The browser receives a ONE-TIME CODE, never tokens. Post it to your own
// backend together with the PKCE verifier; your backend calls
// `exchangeCode` from @lessly/users, sets first-party cookies, and is done.
await fetch('/auth/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: result.code, verifier: attempt.pkceVerifier }),
});
}Why the detour through your backend: a cookie on our host is invisible to your
domain (third-party cookies are dead), and a token handed to a page is a token
an XSS can steal. The code is bound at issuance to the flow's client, the exact
redirect_uri and the PKCE challenge, lives ≤60s, and is single-use.
Who mints the PKCE pair
Two ownership models, and the one above is the browser-owned half: no
codeChallenge, so this client mints the pair and hands the verifier back on
attempt.pkceVerifier for you to post alongside the code.
The other half is BACKEND-owned, and it is what the prebuilt <SignIn/> uses:
your backend mints the pair, keeps the verifier and passes only the challenge in.
const attempt = await users.signIn.create({
identifier: email,
redirectUri: 'https://app.example.com/auth/callback',
codeChallenge, // S256, minted by YOUR backend, which kept the verifier
});
attempt.pkceVerifier; // undefined — this browser never held one, and stores noneUse it whenever the code's redeemer is not this page. A verifier travelling to the same address as the code proves nothing that the code did not already prove.
Session state
Useful when your backend hands the bundle back to the page, or on the trusted (non-browser) path where a completion carries tokens directly.
users.session.setTokens(bundle); // from your backend's exchangeCode
users.session.getState(); // { status, user, session, expiresAt }
await users.session.getToken(); // refreshes first if near expiry
await users.session.refresh(); // concurrent calls are coalesced into one
await users.session.signOut('local'); // 'local' | 'others' | 'global'
const stop = users.session.onSessionChange((state) => render(state));Tokens live in memory only. Nothing writes them to localStorage, and the
library never tries: the durable half of a session belongs in the __Host-
cookie your backend sets.
The email strategies (Phase 3)
A product with a verified mail sender also offers email_code and email_link,
and says so in handle.strategies. Both renderings are ONE token, and both are
verified through the same attempt:
const attempt = await users.start({ identifier, redirectUri });
await attempt.prepare({ strategy: 'email_code' }); // puts an email in flight
await attempt.resend(); // the same strategy again
const result = await attempt.attempt({ strategy: 'email_code', code });prepare answers identically for a known and an unknown address, and its
refusals come back as ordinary flow failures — resend_too_soon inside the
cooldown, email_undeliverable when nothing could be handed to mail.
The magic link's interstitial is two calls, and the split is the security story:
const info = await users.magicLink.info(token); // describes, consumes NOTHING
await users.magicLink.consume(token, { csrfToken: info.csrfToken, attemptId: info.attemptId });On the originating device the client attaches the attempt's stored secret and the link completes. On another device it holds none, so no session is minted there — the answer is the same token's code rendering to type back into the client that started the sign-in.
Recovery, invite and email change (Phase 3)
await users.recovery.requestReset({ identifier }); // always { status: 'sent' }
const info = await users.recovery.info(token);
await users.recovery.completeReset(token, { csrfToken: info.csrfToken, password });
await users.invite.accept(token, { csrfToken, password });
await users.emailChange.start({ newEmail }); // authorized by the live session
await users.emailChange.confirm(token, { csrfToken }); // the NEW address
await users.emailChange.approve(token, { csrfToken }); // the CURRENT primary
await users.emailChange.revert(token, { csrfToken }); // the old address's undoConsuming a reset or an invite answers { status: 'complete' } and NOTHING
else: no token, no session, no user id. The link authorizes exactly one password
set, and the client signs in afterwards through the normal engine — which is
what keeps inbox access alone from bypassing a second factor.
There is no verification call: an address is verified as a side effect of completing an email strategy.
Signing in with a provider (Phase 4)
Two calls, one round trip through Google or GitHub in between.
// The button. It navigates away — everything after this line runs on the
// landing page, in a fresh document.
await users.oauth.start({ provider: 'google', redirectTo: CALLBACK_URL });
// The landing page the 302 came back to (`?attempt=…&status=…`).
const completion = await users.oauth.completeFromCallback();
if (completion?.kind === 'sign_in' && isCodeHandoff(completion.result)) {
const bundle = await myBackend.exchange(completion.result.code, completion.pkceVerifier!);
users.session.setTokens(bundle);
}redirectTo must be one of the product's allowlisted callbacks, byte for byte.
start takes an identifier if a form asked for one and does not need it
otherwise: the account is resolved from the provider's profile, and the
placeholder the client sends instead never becomes anybody's identifier.
The redirect leaves the page, so an OAuth-capable client needs storage that survives a navigation:
createUsersClient({ …, storage: localStorageAdapter() });Without it the landing page holds no client_secret, and
completeFromCallback throws rather than inventing an outcome.
The state is a cookie. prepare answers with a __Host- cookie, so that
call is made with credentials: 'include' — which means the edge in front of
the API has to allow credentialed cross-origin requests. See
docs/oauth-social-login.md §Platform notes.
Settings screens manage what the account can sign in with:
const { identities, unlinkable } = await users.oauth.identities.list();
await users.oauth.identities.link({ provider: 'github', returnUrl: CALLBACK_URL });
await users.oauth.identities.unlink({ identityId });unlinkable is the server's own answer to "what may go?" — render it rather
than recomputing "can't unlink the last way in", and expect
oauth_unlink_last_way_in as a returned failure if you try anyway. Both
mutating calls need a fresh access token (minted within 15 minutes) and
answer 401 otherwise.
The second factor, step-up and devices (Phase 5)
Once a user enrolls an authenticator, every way into their account stops one step short and asks for it:
const attempt = await users.start({ identifier, redirectUri });
let result = await attempt.attempt({ strategy: 'password', password });
if (result.status === 'needs_second_factor') {
// Nothing was minted. The attempt is still open — ask for the code.
result = await attempt.submitSecondFactor(code); // TOTP *or* a backup code
}The completed session claims aal2, and amr names both factors.
Managing the factor is the account plane:
const { secret, otpauthUri } = await users.mfa.enroll(); // shown ONCE
await users.mfa.confirm(codeFromTheApp);
const { codes } = await users.mfa.backupCodes.generate(); // shown ONCE, replaces the old batch
await users.mfa.list();
await users.mfa.disable('totp');Step-up, without assembling a header
Every sensitive call takes the code the user typed. The client turns it into the service's one-time grant, spends it, and forgets it:
await users.account.changePassword({ currentPassword, newPassword, code });
await users.sessions.revokeOthers({ code });Ask first, so a form knows whether to show the box at all:
const { secondFactorRequired } = await users.stepUp.start();For a screen that makes several sensitive calls, verify once and let the client spend what it holds — grants are kept per operation and never spent on another:
await users.stepUp.verify({ operation: 'mfa_enroll', code });
await users.mfa.enroll(); // spends it
await users.mfa.backupCodes.generate(); // needs its ownWith nothing to present the call goes out bare, and the refusal is typed:
StepUpRequiredError (recoverable — prompt and retry),
StaleAuthenticationError (not — send the user back through sign-in),
StepUpUnavailableError (an outage, with retryAfter).
The device list
const { sessions } = await users.sessions.list(); // current / aal / amr / impersonated
await users.sessions.revoke(id, { code }); // somebody else's device
await users.sessions.revoke(id, { current: true }); // this one — never gated
await users.sessions.revokeOthers({ code });Revoked and expired rows are included on purpose: "you were signed out on that laptop an hour ago" is what somebody checking this screen is looking for.
Storage adapters
Only the in-flight attempt handle and its client_secret are stored, so a
reload mid sign-in can users.resume(attemptId).
import { localStorageAdapter } from '@lessly/users-client';
createUsersClient({ …, storage: localStorageAdapter() });The default is memory. localStorageAdapter is opt-in and the tradeoff is
explicit: anything with XSS on the page can read it. What is exposed is one
short-lived, single-completion attempt credential — never a session.
Errors
Flow outcomes are returned, not thrown: a wrong password is
{ status: 'failed', error: { code } } with HTTP 200, because a uniform body is
the enumeration defence. Only transport conditions throw — AuthenticationError
(401, with a factual environment hint), NotFoundError (404),
RateLimitedError (429, retryAfter), StepUpRequiredError (403),
StaleAuthenticationError (401), StepUpUnavailableError (503), NetworkError,
UsersApiError.
Unknown, additively-introduced statuses are handed back unchanged instead of throwing, so an old client degrades gracefully when the server grows.
The publishable key header
The key travels in x-publishable-key. It is deliberately not sent in
x-api-key: that header is the code-exchange leg's credential, and the service
treats its presence as "a backend is calling" — sending a page-scrapable key
there would switch off the per-product origin allowlist that protects your
product's browser flows.
Upgrading
0.6.0 is a BREAKING release (pre-1.0, so a minor). It renames the
appearance layer (Hosted* → Auth*) and removes
hostedThemeCss. See CHANGELOG.md for the full table and
what to replace hostedThemeCss with.
Related
@lessly/users— the server SDK your backend uses (exchangeCode,verifyToken, middleware)@lessly/users-react— hooks and prebuilt components over this core
