@baliola/auth-sdk
v0.2.0
Published
Client SDK for Baliola Auth
Readme
@baliola/auth-sdk
Client SDK for the Baliola Auth service. Two clearly separated email flows (passwordless OTP, password + verification OTP) plus Google login, with typed methods, typed errors, auto-refresh, and cross-tab sync.
For deeper detail see docs/concepts.md.
Install
bun add @baliola/auth-sdk
# or: npm install @baliola/auth-sdk
# or: pnpm add @baliola/auth-sdkPublished to public npm under the @baliola scope — no registry config or auth token required.
Runtime: Bun (any recent), Node 20.3+, Chrome 116+, Safari 17+, Firefox 124+. Zero runtime deps. ESM only.
Quick start
import { createAuthClient, localStorageStore, OtpInvalidError } from '@baliola/auth-sdk';
const auth = createAuthClient({
baseUrl: 'https://baliola-auth.baliola.dev',
clientId: 'your-project-client-id', // omit for a central multi-project login
store: localStorageStore(), // omit for in-memory (server-side / tests)
});
await auth.loadSession(); // rehydrate from store at app boot
// Passwordless login
const { otp } = await auth.emailOtp.sendLoginCode({
email: '[email protected]',
captchaToken: turnstileToken,
});
showOtpForm({ resendIn: otp.canResendInSeconds });
try {
const session = await auth.emailOtp.verifyLoginCode({
email: '[email protected]',
code: '123456',
});
} catch (e) {
if (e instanceof OtpInvalidError) {
showError(`Wrong code. ${e.attemptsRemaining} tries left.`);
} else throw e;
}
auth.isAuthenticated(); // true
await auth.logout();API
The SDK is namespaced by flow so that each method's purpose is unambiguous at the call site.
auth.emailOtp — passwordless flow
auth.emailOtp.sendLoginCode({ email, captchaToken });
// → { flow: 'login'|'signup', methods: ('password'|'passwordless')[], otp: OtpInfo }
auth.emailOtp.verifyLoginCode({ email, code });
// → AuthSession (also populates the session mirror)
auth.emailOtp.resendLoginCode({ email, captchaToken });
// → { otp: OtpInfo }auth.emailPassword — password flow
auth.emailPassword.register({ email, password, captchaToken });
// → { otp: OtpInfo } (sends a verification OTP; bcrypt hash held server-side)
auth.emailPassword.verifyRegistrationCode({ email, code });
// → AuthSession (finalizes the account; populates the session mirror)
auth.emailPassword.resendRegistrationCode({ email, captchaToken });
// → { otp: OtpInfo }
auth.emailPassword.login({ email, password, captchaToken });
// → AuthSession
// Authenticated:
auth.emailPassword.setPassword({ password });
auth.emailPassword.changePassword({ currentPassword, newPassword });
// ↑ revokes every other session for the account on successauth.google
// idToken is obtained from Google Sign-In on the frontend.
auth.google.login({ idToken }); // → AuthSessionSession lifecycle
await auth.refresh(); // → AuthSession (manual; SDK also refreshes proactively/reactively)
await auth.logout(); // → void
auth.getSession(); // AuthSession | null (sync)
auth.isAuthenticated(); // boolean (sync)
await auth.loadSession(); // rehydrate from store at app boot
const unsub = auth.onSessionChange((session) => {
/* AuthSession | null */
});
auth.onSessionChange(handler, { immediate: false }); // future-only
unsub();auth.fetch — call your own backend
Authorization: Bearer … is attached automatically; X-Session-ID is never sent to consumer URLs. 401 → /auth/refresh → retry-once happens transparently.
const res = await auth.fetch('https://api.example/things', {
method: 'POST',
body: JSON.stringify({ ping: 1 }),
signal: ctrl.signal,
requireAuth: true, // throw AuthError(401) before sending if no session
disableRefresh: true, // skip both proactive + reactive refresh for this call
});
if (!res.ok) {
/* res.status, res.json(), etc. */
}auth.fetch returns the Response for any consumer-URL non-2xx. It only throws AuthError when the SDK's own /auth/refresh sub-call fails.
Lifecycle
auth.close(); // release cross-tab BroadcastChannel; optionalErrors
Every error subclass extends AuthError. Branch on instanceof (preferred for TS narrowing) or err.code.
import {
AuthError,
OtpInvalidError,
OtpExpiredError,
MaxAttemptsError,
ResendCooldownError,
MaxResendsError,
NoPendingOtpError,
InvalidCredentialsError,
NoPasswordSetError,
AccountSuspendedError,
EmailAlreadyHasPasswordError,
CaptchaFailedError,
RateLimitedError,
InvalidPasswordError,
InvalidEmailError,
} from '@baliola/auth-sdk';
try {
await auth.emailOtp.verifyLoginCode({ email, code });
} catch (e) {
if (e instanceof OtpInvalidError)
return show(`${e.attemptsRemaining} tries left, resend in ${e.canResendInSeconds}s`);
if (e instanceof OtpExpiredError) return showResendPrompt();
if (e instanceof MaxAttemptsError) return show(`Too many tries. Wait ${e.retryAfterSeconds}s`);
if (e instanceof RateLimitedError) return show(`Rate limited. ${e.retryAfterHuman}`);
throw e;
}| Error class | code | Status | Carries |
| ------------------------------ | ---------------------------- | ------ | ------------------------------------------ |
| OtpInvalidError | otp_invalid | 400 | attemptsRemaining, canResendInSeconds |
| OtpExpiredError | otp_expired | 400 | — |
| MaxAttemptsError | max_attempts_exceeded | 429 | retryAfterSeconds |
| NoPendingOtpError | no_pending_otp | 400 | — |
| ResendCooldownError | resend_cooldown | 429 | canResendInSeconds |
| MaxResendsError | max_resends_exceeded | 429 | — |
| InvalidCredentialsError | invalid_credentials | 401 | — |
| NoPasswordSetError | no_password_set | 400 | — |
| AccountSuspendedError | account_suspended | 403 | — |
| EmailAlreadyHasPasswordError | email_already_has_password | 409 | — |
| CaptchaFailedError | captcha_failed | 400 | — |
| InvalidPasswordError | invalid_password | 400 | reason: 'too_short'\|'no_uppercase'\|... |
| InvalidEmailError | invalid_email | 400 | — |
| RateLimitedError | (legacy 429) | 429 | retryAfterSeconds, retryAfterHuman |
| AuthError (base) | varies | varies | status: 0 for network/timeout |
Stores
import { memoryStore, localStorageStore, type SessionStore } from '@baliola/auth-sdk';
memoryStore(); // default — per-process, no persistence
localStorageStore({ key? }); // browser only, persists across reloads
const cookieStore: SessionStore = {
get() { /* AuthSession | null */ },
set(s) { /* persist or clear when s === null */ },
};Cross-tab sync (login / logout / refresh) is automatic via BroadcastChannel, regardless of store choice.
Configuration
createAuthClient({
baseUrl: 'https://baliola-auth.baliola.dev', // required, no trailing slash
clientId: 'your-project-client-id', // optional; scopes the session to one project. Omit for a central login that carries every project the account has active roles in.
store: memoryStore(), // default; or localStorageStore() / custom
fetch: globalThis.fetch, // override for tests / interceptors
timeoutMs: 15_000, // per-request, default 15s
proactiveRefresh: { leadTimeMs: 60_000 }, // or `false` to disable; default 60s lead
onError: (e, source) => console.error('[auth-sdk]', source, e),
});clientId is constructor-only — there is no per-call override. To switch projects, create a new client.
Decoding the JWT
The SDK does not parse the access token; consumers that need claims (e.g. server-side authorization in a Node service) decode it with their own library. The exported AccessTokenPayload type matches the server's current claim shape.
import { decodeJwt } from 'jose';
import type { AccessTokenPayload } from '@baliola/auth-sdk';
const session = auth.getSession();
if (session) {
const claims = decodeJwt<AccessTokenPayload>(session.accessToken);
// claims.accountId, claims.email
// claims.projects: { id, name, clientId }[] // single entry for a scoped login; many for a central login
// claims.roles?: string[]; claims.permissions?: string[]
}Sample response shapes
auth.emailOtp.sendLoginCode success:
{
"flow": "signup",
"methods": ["passwordless"],
"otp": {
"expiresInSeconds": 300,
"expiresAt": "2026-05-04T12:05:00.000Z",
"canResendInSeconds": 60,
"resendsRemaining": 3
}
}auth.emailPassword.register / resend success:
{
"otp": {
"expiresInSeconds": 300,
"expiresAt": "...",
"canResendInSeconds": 60,
"resendsRemaining": 3
}
}auth.emailOtp.verifyLoginCode / auth.emailPassword.verifyRegistrationCode / auth.emailPassword.login / auth.google.login success (AuthSession):
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "session-uuid",
"expiresIn": 3600,
"account": { "id": "...", "email": "[email protected]", "status": "active" },
"project": { "id": "...", "name": "...", "displayName": null, "allowedOrigins": [...] },
"roles": ["user"],
"permissions": []
}Verify-code failure (e.g. OtpInvalidError):
{
"message": "Invalid verification code. 2 attempt(s) remaining.",
"data": null,
"error": {
"code": "otp_invalid",
"details": {
"name": "OtpInvalidError",
"attemptsRemaining": 2,
"canResendInSeconds": 30
}
}
}License
Proprietary — see LICENSE.
