@vexxen/auth
v0.14.0
Published
Cliente de autenticación para Vexxen
Readme
@vexxen/auth
TypeScript client for the Vexxen authentication API. It handles registration, login, token refresh, user info and the Google sign-in flow, with optional React components.
- Zero storage: the library never stores tokens. You decide where to keep them.
- ESM only, fully typed.
- React is an optional peer dependency (only needed for
@vexxen/auth/react).
What's new in 0.14.0
getAccess(): new field transferredTo. Users can now transfer a subscription to another Vexxen account from their Vexxen dashboard (the other person accepts it there). After that, the previous owner's access says who has it now, so your app can hand over whatever depended on it. See Product access. No breaking changes.
What's new in 0.13.0
- Two-step verification (a code sent by email after the password). Users turn it on from their Vexxen profile. The popup (
authorizeWithPopup) asks for the code by itself: nothing to change. If you calllogin()directly, it now throwsVexxenTwoFactorRequiredErrorfor those users: finish withcompleteTwoFactorLogin. See Two-step verification. getAccess(): new statustrialing(free trial of a paid plan) and fieldtrialEndsAt. Treattrialinglikeactive.
No breaking changes for apps that sign in with the popup.
What's new in 0.11.0
getAccess(userId) and startFreePlan(userId): from your server, check which plan of your product a user has (paid, free or none) and give them the free plan. See Product access. No breaking changes.
Upgrading to 0.10.0
Users always sign in with Vexxen (email and password). Google is no longer a way to sign in: it is a connected account that apps can use behind the scenes (see Apps that use Google).
- Breaking:
authorizeWithPopup({ provider: "google" })andAuthorizeProviderare gone. The popup ignores the oldprovider=googleparameter and shows the Vexxen form.SignInWithGoogleButtonis again only a styled button: it no longer signs in. - New:
authorizeWithPopup({ connectGoogle: true }),getGoogleAccessToken(userId)andVexxenGoogleNotConnectedError. - New:
requestPasswordReset(email)andchangePassword(accessToken, request).
Upgrading to 0.8.0
Breaking: register() no longer returns tokens. Users must verify their email (Vexxen sends them a link) before they can sign in. register() now returns { status: "verification_required", email, verificationEmailSent, googleLinkUrl? }, login() throws VexxenEmailNotVerifiedError until the email is verified, and registerWithGoogle's onBeforeRedirect receives that result instead of tokens. New: resendVerificationEmail(email).
Installation
bun add @vexxen/auth
# or
npm install @vexxen/authReact bindings require React 18 or newer:
bun add reactQuick start
import { VexxenAuth } from "@vexxen/auth";
const auth = new VexxenAuth({
clientId: "your-client-id",
apiKey: "your-api-key",
// Optional: enable Google sign-in
google: {
redirectUri: "https://your-app.com/auth/callback",
},
});Never expose your API key in client-side code that ships to the browser. Call the library from your server (for example a Next.js Route Handler or Server Action).
API
new VexxenAuth(config)
Creates a client. Throws VexxenAuthConfigError if the configuration is invalid.
apiKey is optional since 0.2.0: it is only needed for getAccess, startFreePlan, register, resendVerificationEmail, requestPasswordReset, changePassword, getGoogleAccessToken, login, refreshToken, userInfo, updateUserInfo, updateAvatar, removeAvatar, getBillingDetails, updateBillingDetails, deleteAccount and logout (calling them without it throws VexxenAuthConfigError). authorizeWithPopup only needs clientId.
auth.register(request): Promise<RegisterResult>
Registers a new user. Registering doesn't sign the user in (since 0.8.0): Vexxen emails them a link to verify their address, and until they open it login fails with VexxenEmailNotVerifiedError. Show a "check your inbox" message.
const result = await auth.register({ /* RegisterRequest */ });
// { status: "verification_required", email: "[email protected]", verificationEmailSent: true, googleLinkUrl?: "…" }When Google sign-in is enabled, the result also includes a Google link URL the user must be redirected to.
auth.resendVerificationEmail(email): Promise<void>
Sends a new verification link. Vexxen always answers the same way (it never reveals whether the account exists or is already verified) and limits it to one email per minute and five per day.
await auth.resendVerificationEmail("[email protected]");auth.parseGoogleCallback(params: URLSearchParams)
Parses the parameters Vexxen appends when redirecting back to your app after the Google flow.
const data = auth.parseGoogleCallback(new URLSearchParams(window.location.search));auth.login(request): Promise<Tokens>
Logs a user in and returns the tokens. If the password is right but the email isn't verified yet, it throws VexxenEmailNotVerifiedError (offer resendVerificationEmail). If the user has two-step verification, it throws VexxenTwoFactorRequiredError (see below).
const tokens = await auth.login({ /* LoginRequest */ });
// tokens.accessToken, tokens.refreshTokenTwo-step verification
Users turn it on from their Vexxen profile. From then on, after the password, Vexxen emails them a 6-digit code (valid for 10 minutes). If they can't get the email, they can type one of their recovery codes instead (each works once).
With the popup (authorizeWithPopup) Vexxen asks for the code itself: you get the tokens as always.
With login() you ask for the code:
import { VexxenTwoFactorRequiredError } from "@vexxen/auth";
try {
return await auth.login({ email, password });
} catch (err) {
if (!(err instanceof VexxenTwoFactorRequiredError)) throw err;
// err.challenge: { challenge, email: "j***@example.com", expiresAt }
const code = await askTheUserForTheCode(err.challenge.email);
return await auth.completeTwoFactorLogin({ challenge: err.challenge.challenge, code });
}auth.resendTwoFactorCode(challenge)emails a new code (the previous one stops working). One per minute, five per sign-in.- Wrong code:
VexxenApiErrorwith codeINVALID_TWO_FACTOR_CODE(the message says how many attempts are left). Expired code:EXPIRED_TWO_FACTOR_CODE(ask for another). After five wrong codes, or with a challenge that's used, expired or from another app:INVALID_TWO_FACTOR_CHALLENGE, and the user has to enter their password again. Too many requests:TWO_FACTOR_RATE_LIMITED(429). - The challenge only works in the app (client) that called
login().
Managing it (what the Vexxen profile does; every call needs the user's access token and, except the status, their password):
| Method | What it does |
| --- | --- |
| getTwoFactorStatus(accessToken) | { enabled, enabledAt, recoveryCodesLeft } |
| startTwoFactorSetup(accessToken, password) | Emails a code to turn it on. Returns a TwoFactorChallenge. PASSWORD_NOT_SET if the account has no password. |
| enableTwoFactor(accessToken, { challenge, code }) | Turns it on. Returns { recoveryCodes }: show them now, they can't be read again. |
| disableTwoFactor(accessToken, password) | Turns it off and deletes the recovery codes. |
| regenerateRecoveryCodes(accessToken, password) | New { recoveryCodes }; the old ones stop working. |
Vexxen emails the user every time it's turned on or off, or recovery codes are regenerated.
auth.refreshToken(request): Promise<Tokens>
Exchanges a refresh token for a new set of tokens.
auth.userInfo(accessToken): Promise<VexxenUser>
Returns the profile of the user that owns the access token.
const user = await auth.userInfo(tokens.accessToken);auth.updateUserInfo(accessToken, request): Promise<VexxenUser>
Lets the user who owns the access token update their own profile. Returns the updated user.
const user = await auth.updateUserInfo(tokens.accessToken, {
firstName: "Alex",
phone: "+34 600 000 000", // null or "" removes it
});Only firstName, lastName and phone can be changed; any other field (for example email) is rejected with a VexxenApiError (400).
auth.updateAvatar(accessToken, image): Promise<VexxenUser>
Sets the profile picture of the user who owns the access token. image is a Blob (or File) in JPEG, PNG or WebP format, up to 1 MB. Returns the updated user, whose avatarUrl points to the new picture.
const user = await auth.updateAvatar(tokens.accessToken, file);
// user.avatarUrl → "https://…/api/avatars/<userId>?v=…"Crop the image to a square and scale it down before uploading (for example to 256×256): the picture is shown small and every app downloads it. Other formats or larger files are rejected with a VexxenApiError (400, code INVALID_AVATAR). The URL changes with every new picture, so it can be cached forever.
auth.removeAvatar(accessToken): Promise<VexxenUser>
Removes the profile picture. Returns the updated user, with avatarUrl: null.
auth.getBillingDetails(accessToken): Promise<BillingDetails>
Returns the billing details of the user who owns the access token. They are shared by every Vexxen app. If the user has never filled them in, you get their full name as legalName, "ES" as country and null everywhere else.
const billing = await auth.getBillingDetails(tokens.accessToken);
// { legalName, taxId, address: { line1, line2, postalCode, city, state, country } }The address uses the same shape as Stripe's Customer.address.
auth.updateBillingDetails(accessToken, request): Promise<BillingDetails>
Replaces the user's billing details and returns them as stored. Empty strings are stored as null. country must be an ISO 3166-1 alpha-2 code ("ES", "PT", "MX"…) and defaults to "ES".
await auth.updateBillingDetails(tokens.accessToken, {
legalName: "Acme SL",
taxId: "B12345678",
address: { line1: "Calle Velázquez, 34", postalCode: "28001", city: "Madrid", country: "ES" },
});Card data never goes through this API: payment methods are handled by Stripe.
auth.requestPasswordReset(email): Promise<void>
"Forgot password?": Vexxen emails the user a link to choose a new password. Accounts without a password (for example, migrated from another app) get a link to create one. Vexxen always answers the same way (it never reveals whether the account exists) and limits it to one email per minute and five per day.
await auth.requestPasswordReset("[email protected]");The link opens a Vexxen page where the user picks the password. It expires in 1 hour (7 days for creating a first password) and works once. Saving it verifies the email and signs the user out of every app.
auth.changePassword(accessToken, request): Promise<void>
Changes the password of the user who owns the access token. The current password is required, so a stolen access token is not enough. Every other session of the user, in every app, stops working. Pass this session's refreshToken to keep it open.
await auth.changePassword(tokens.accessToken, {
currentPassword: "old-password",
newPassword: "new-password", // at least 8 characters
refreshToken: tokens.refreshToken, // optional: keep this session
});| VexxenApiError | When |
| --- | --- |
| status 403, code "INVALID_PASSWORD" | Wrong current password. |
| status 409, code "PASSWORD_NOT_SET" | The account has no password yet: use requestPasswordReset to create one. |
| status 400 | The new password is too short. |
auth.deleteAccount(accessToken, request): Promise<void>
Deletes the account of the user who owns the access token. The user must confirm with their password, so a stolen access token is not enough. Accounts without a password (Google only) confirm with their email instead.
await auth.deleteAccount(tokens.accessToken, { password });
// accounts without password: { confirmEmail: "[email protected]" }The account is anonymized: personal data is erased and the email becomes free to register again. Every session of the user, in every app, stops working at once. Delete the tokens you stored.
| VexxenApiError | When |
| --- | --- |
| status 403, code "INVALID_PASSWORD" | Wrong password. |
| status 403, code "INVALID_CONFIRMATION" | confirmEmail does not match. |
| status 400, code "CONFIRMATION_REQUIRED" | Missing confirmation. |
auth.logout(request): Promise<void>
Revokes a refresh token, so it can no longer be exchanged for new tokens. Call it when the user signs out. The access token stays valid until it expires (15 minutes), so also delete it from wherever you store it.
await auth.logout({ refreshToken: tokens.refreshToken });auth.authorizeWithPopup(options?): Promise<Tokens>
Opens the Vexxen sign-in page (<baseUrl>/auth/authorize) in a popup, like "Sign in with Google". The user logs in or creates an account there and the popup sends the tokens back to your app with postMessage. Only clientId is needed, so this is the flow to use in the browser: no API key required.
const auth = new VexxenAuth({ clientId: "your-client-id", baseUrl: "https://vexxen.example.com" });
button.addEventListener("click", async () => {
const tokens = await auth.authorizeWithPopup({ mode: "login" }); // or "register"
});connectGoogle: true(apps that use Google): after signing in, the popup asks the user to connect Google even if they said "Not now" before.- Call it directly from a click handler: browsers block popups opened after an
await. - Your app's origin (for example
http://localhost:3000) must be registered in the client'sredirectUrison the Vexxen side; otherwise the popup refuses to continue. - If the user closes the popup, the promise rejects with
VexxenPopupClosedError.
Product access
If your app is a product of the Vexxen catalog (Vexxen links your client to it), your server can check what each user has of it and decide what they can do. Vexxen is the source of truth for plans and payments: users subscribe, upgrade and cancel in their Vexxen dashboard.
const access = await auth.getAccess(vexxenUserId); // the `id` from userInfo()
// {
// productId: "planify",
// access: true,
// status: "active", // "active" | "trialing" | "canceling" | "past_due" | "free" | "none"
// plan: { id: "pro", name: "Pro", paid: true },
// cycle: "monthly",
// currentPeriodEnd: "2026-11-08T10:00:00.000Z",
// cancelAtPeriodEnd: false,
// trialEndsAt: null, // end of the free trial, if the plan started with one
// transferredTo: null, // they transferred the subscription: the Vexxen user id who has it now
// accessEndsAt: null,
// }
if (!access.access) {
// No plan: block the app (or give them the free plan, below)
}| status | access | When |
| --- | --- | --- |
| active | yes | Paid plan, up to date. |
| trialing | yes | Free trial of a paid plan: treat it like active. The first charge is at trialEndsAt. |
| canceling | yes | Canceled: access until accessEndsAt (end of the period), then back to the free plan if there is one. |
| past_due | yes | A renewal payment failed: access until accessEndsAt (end of the grace period) unless they pay. |
| free | yes | Free plan (or their paid plan ended and the product has a free plan). |
| none | no | They don't have the product. |
Transfers. A subscription can be transferred to another Vexxen account (the recipient accepts it in their dashboard). The new owner gets the same plan right away and pays from the end of the period already paid. The previous owner loses the paid plan (free if your product has a free plan, otherwise none) and their access has transferredTo set to the new owner's Vexxen user id: use it to move to them whatever they were paying for, for example a company account.
startFreePlan(userId) gives the user the product's free plan if they don't have the product yet (it never touches a paid plan) and returns their access. Call it, for example, the first time a user signs in to your app.
let access = await auth.getAccess(vexxenUserId);
if (!access.access) access = await auth.startFreePlan(vexxenUserId);| VexxenApiError | When |
| --- | --- |
| status 403, code "CLIENT_WITHOUT_PRODUCT" | Your client isn't linked to a product: ask Vexxen to link it. |
| status 404, code "USER_NOT_FOUND" | The account doesn't exist or was deleted: sign the user out. |
| status 409, code "NO_FREE_PLAN" | startFreePlan: the product has no free plan. |
Both need the apiKey: call them from your server. To let users upgrade, link to <vexxen>/dashboard/catalog?product=<productId>: it opens your product's plans.
Apps that use Google
Users always sign in with Vexxen. If your app uses Google APIs on the user's behalf (for example Google Calendar), Vexxen keeps the user's Google connection and gives your server Google access tokens:
- Vexxen enables Google for your client with the Google scopes your app needs and a short purpose, for example "sync your deadlines with Google Calendar".
- After signing in, the popup asks the user: "Your app uses your Google account to sync your deadlines with Google Calendar." Connect Google takes them to Google; Not now signs them in without Google, and they are not asked again. Either way your app gets the Vexxen tokens as usual.
- From your server, get a Google access token for the user, limited to your app's scopes:
import { VexxenGoogleNotConnectedError } from "@vexxen/auth";
try {
const google = await auth.getGoogleAccessToken(vexxenUserId); // the `id` from userInfo()
// { accessToken, expiresAt, scopes } → call Google APIs with `Authorization: Bearer ${google.accessToken}`
} catch (err) {
if (err instanceof VexxenGoogleNotConnectedError) {
// The user hasn't connected Google to your app (or revoked it): ask them in the browser
// with auth.authorizeWithPopup({ connectGoogle: true })
}
}getGoogleAccessTokenneeds theapiKey: call it from your server, never from the browser. Each call asks Google for a new token, so reuse it untilexpiresAt.- The token only carries your app's scopes, even if the user's Google account granted more to other Vexxen apps.
authorizeWithPopup({ connectGoogle: true })asks again even if the user said "Not now". If they cancel, they are still signed in, without Google.
React
import { VexxenAuthProvider, useVexxenAuth } from "@vexxen/auth/react";The @vexxen/auth/react entry point exports VexxenAuthProvider, the useVexxenAuth hook and ready-made buttons (VexxenAuthButton, SignInWithGoogleButton, SignInWithVexxenButton). The components use plain HTML/CSS, with no UI framework dependency.
SignInWithVexxenButton
A "Sign in with Google"-style button with the Vexxen lion logo. Clicking it calls authorizeWithPopup.
import { VexxenPopupClosedError } from "@vexxen/auth";
import { VexxenAuthProvider, SignInWithVexxenButton } from "@vexxen/auth/react";
const config = { clientId: "your-client-id", baseUrl: "https://vexxen.example.com" };
export function Login() {
return (
<VexxenAuthProvider config={config}>
<SignInWithVexxenButton
mode="login" // "register" opens the sign-up screen
onSuccess={(tokens) => console.log(tokens)}
onError={(err) => {
if (!(err instanceof VexxenPopupClosedError)) console.error(err);
}}
/>
</VexxenAuthProvider>
);
}Props: mode ("login" | "register", default "login"), label, onSuccess, onError, popupWidth, popupHeight, plus any <button> attribute. Default labels: "Continuar con Vexxen" / "Registrarse con Vexxen".
SignInWithGoogleButton
Only a button styled like Google's (props: label, loading, plus any <button> attribute). It does nothing by itself: users sign in with Vexxen.
useVexxenAuth() also exposes authorizeWithPopup(options) (including connectGoogle), which returns Tokens | null and tracks isLoading / error like login and register.
Error handling
All errors extend a common base so you can handle them selectively:
import {
VexxenApiError,
VexxenAuthConfigError,
VexxenAuthError,
VexxenEmailNotVerifiedError,
VexxenGoogleNotConnectedError,
VexxenInvalidCredentialsError,
VexxenPopupClosedError,
VexxenTwoFactorRequiredError,
} from "@vexxen/auth";
try {
await auth.login({ /* ... */ });
} catch (err) {
// VexxenApiError has: message (the API's message), status, code (e.g. "EMAIL_TAKEN") and body.
if (err instanceof VexxenInvalidCredentialsError) {
// wrong email or password
} else if (err instanceof VexxenTwoFactorRequiredError) {
// ask for the emailed code: auth.completeTwoFactorLogin({ challenge: err.challenge.challenge, code })
} else if (err instanceof VexxenApiError) {
// the API returned an error
} else {
throw err;
}
}Exported types
AccessStatus, AuthConfig, AuthorizeMode, AuthorizePopupOptions, ChangePasswordRequest, CompleteTwoFactorLoginRequest, EnableTwoFactorRequest, RecoveryCodes, TwoFactorChallenge, TwoFactorStatus, GoogleAccessToken, ProductAccess, BillingAddress, BillingDetails, DeleteAccountRequest, UpdateBillingDetailsRequest, GoogleAuthOptions, LoginRequest, LogoutRequest, RefreshTokenRequest, UpdateUserInfoRequest, RegisterRequest, RegisterResult, Tokens, VexxenUser, AuthResult.
Development
bun install
bun run typecheck # tsc --noEmit
bun run build # tsup -> dist/Manual test scripts (they hit the real API, so set up your environment first):
bun run try:register
bun run try:login
bun run try:refresh-userinfoReleasing a new version
This package follows Semantic Versioning:
| Change | Command | Example |
| ------------------------------ | ------------------- | --------------- |
| Bug fix, no API change | npm version patch | 0.1.0 -> 0.1.1 |
| New feature, backwards compatible | npm version minor | 0.1.1 -> 0.2.0 |
| Breaking change | npm version major | 0.2.0 -> 1.0.0 |
Release checklist:
git status # working tree must be clean
npm version patch # bumps package.json, commits and tags
npm pack --dry-run # only dist/ and package.json should appear
npm publish # prepublishOnly runs typecheck + build
git push --follow-tagsPublishing requires an npm account with two-factor authentication enabled for "Authorization and publishing", or a granular access token with 2FA bypass. A published version cannot be overwritten: if you make a mistake, publish a new version and, if needed, run npm deprecate @vexxen/auth@<version> "reason".
Pre-release versions can be published under a separate tag so they are not installed by default:
npm version prerelease --preid=beta # 0.2.0-beta.0
npm publish --tag beta
bun add @vexxen/auth@betaLicense
MIT
