@auth-craft/client
v0.2.0
Published
Contract-driven typed frontend client for Auth Craft. Methods are generated from @auth-craft/api-contract; tokens are isolated in a Web Worker. Import the root for all groups, or @auth-craft/client/core for a scope-agnostic core you wire per scope.
Downloads
325
Readme
@auth-craft/client
Typed, contract-driven frontend client for Auth Craft.
Every method is generated from @auth-craft/api-contract — the same endpoint
declarations the backend routes validate against. So the client's paths, request
bodies, query params, path params and response types cannot drift from the
server: if a route changes and the contract is updated, your call sites re-type
at build time. Tokens are isolated in a Web Worker (a vendored transport,
absorbed from FetchGuard), protecting them from XSS.
One scope per app? Import
@auth-craft/client/core
- only the contract groups you need, so other scopes' code stays out of your bundle. The root export below wires every group.
Migrating from the old
@auth-craft/frontend-sdk? Jump to Migration.
Install
npm install @auth-craft/clientBundler note (Vite)
The client runs a Web Worker. With Vite, exclude it from pre-bundling:
// vite.config.ts
export default defineConfig({
optimizeDeps: { exclude: ['@auth-craft/client'] }
})If you bundle the SDK to plain JS (e.g. consuming from dist/), pass a
workerFactory so the worker URL resolves in your app (see Config).
Quick start
import { createAuthCraftClient } from '@auth-craft/client'
const client = createAuthCraftClient({
baseUrl: 'https://auth.example.com',
strategy: 'cookie-auth', // or 'body-auth'
allowedDomains: ['auth.example.com']
})
// One-time init — boots the Worker and restores any existing session.
await client.init()
// Log in. Returns a discriminated result: tokens | challenge | mfa | verification.
const r = await client.auth.authenticate({
body: { identityType: 'email', identityValue: '[email protected]', credentialType: 'password', credentialValue: 'secret' }
})
if (r.ok) {
// session established → call authenticated endpoints
const me = await client.auth.me()
}The call shape
Every generated method takes one argument object with up to three keys — always the same shape, so you never have to remember a per-method signature:
client.<group>.<method>({
params?: { ...pathParams }, // required only when the path has :params
body?: { ...requestBody }, // present only when the endpoint has a body
query?: { ...queryParams } // present only when the endpoint has a query
})paramsis required when the path contains a segment like:tenantId.bodyis typed from the contract's request schema (missing/extra fields are a compile error).queryis always optional.
Every method returns Promise<Result<T>> from
ts-micro-result:
const r = await client.auth.me()
if (r.ok) {
console.log(r.data.displayName) // typed from the contract response schema
} else {
console.error(r.errors[0].code) // backend error codes, forwarded as-is
}Config
interface AuthCraftClientConfig {
/** Base URL of the Auth Craft backend (no trailing slash needed). */
baseUrl: string
/** Refresh-token delivery: 'cookie-auth' (httpOnly cookie) or 'body-auth'. */
strategy: 'cookie-auth' | 'body-auth'
/** Hosts the Worker may attach tokens to (anti-exfiltration allow-list). */
allowedDomains: string[]
/** Refresh this many ms before access-token expiry (proactive). */
refreshEarlyMs?: number
/** Headers added to every request (e.g. an edge-gate secret). */
defaultHeaders?: Record<string, string>
/** Custom Worker factory — required when consuming the SDK as compiled JS. */
workerFactory?: () => Worker
/**
* Where the token loop runs. 'worker' (default) isolates tokens off-thread;
* 'inline' runs it on the main thread — no worker to configure, but tokens
* become reachable by page XSS. See "Transport: worker vs inline" below.
*/
transport?: 'worker' | 'inline'
/**
* Auth scope → sent as the `X-Auth-Context` header on every request.
* 'system' = admin, 'tenant' = tenant-scoped, omit/'customer' = default.
*/
context?: 'customer' | 'tenant' | 'system'
/**
* Subscribe to session state changes (login, logout, refresh, expiry).
* Fires after `init()`. Bridge it into app state or trigger auto-logout
* on `authenticated: false`.
*/
onAuthStateChanged?: (state: { authenticated: boolean; user?: unknown; expiresAt?: number | null }) => void
}Transport: worker vs inline
By default the client runs its token/refresh loop in a Web Worker, so the
access & refresh tokens live in a separate realm the page's own JavaScript
cannot reach. That is the XSS protection: even if a script is injected into your
page, it has no reference to the tokens and can only talk to the loop through the
same postMessage protocol you do.
Some apps can't or don't want to spawn a worker — no bundler worker support, a
strict CSP, SSR/edge constraints, or simply not wanting the Vite
optimizeDeps.exclude / workerFactory setup. For those, set
transport: 'inline':
const client = await createClientCore({
baseUrl: 'https://auth.example.com',
strategy: 'cookie-auth',
allowedDomains: ['auth.example.com'],
transport: 'inline', // run the loop on the main thread — no Worker
})The exact same loop runs, just on the main thread instead of off it. No worker file is loaded, so no bundler worker configuration is needed.
Security trade-off. Inline means the loop — and the tokens it holds — live on the main thread. There is no Worker realm boundary, so page XSS can reach the tokens. Prefer
'inline'only withstrategy: 'cookie-auth', where the refresh token is an httpOnly cookie that never enters JavaScript anyway (so the worker was adding little for the refresh token). Withstrategy: 'body-auth'the refresh token lives in JS, and the Worker is its main XSS defense — keep the defaulttransport: 'worker'there.
transportis ignored when you pass aworkerFactory(the factory decides the host).
Method groups
client.<group>.<method>(...). Authenticated unless marked public.
auth
| Method | HTTP | Notes |
|---|---|---|
| authenticate({ body }) | POST /auth/authenticate | public, login — returns tokens / challenge / mfa / verification |
| register({ body }) | POST /auth/register | public — returns { userId, verification? } (does NOT log in) |
| completeChallenge({ body }) | POST /auth/complete-challenge | public, login — finishes a 2-step credential challenge |
| completeMfaChallenge({ body }) | POST /auth/complete-mfa-challenge | public, login — finishes MFA |
| issueMfaChallenge({ body }) | POST /auth/issue-mfa-challenge | public — send an MFA code (SMS/email) |
| me() | GET /auth/me | current user profile |
| selectTenant({ body }) | POST /auth/select-tenant | switch tenant context (re-issues tokens) |
| logout() | POST /auth/logout | clears the session |
refreshis handled automatically by the transport (proactive, before expiry). You don't call it.
password
changePassword({ body }) · forgotPassword({ body }) (public) · resetPassword({ body }) (public)
verification
verifyCode({ body }) (public) · resendVerification({ body }) (public)
session
listSessions({ query? }) · deleteSession({ body }) · revokeToken({ body })
mfa
setupAuthenticator({ body }) · verifyAuthenticator({ body }) · listAuthenticators({ query? }) · removeAuthenticator({ body }) · updateMfaSettings({ body })
MFA verify: the code goes inside
data, e.g.verifyAuthenticator({ body: { method: 'totp', data: { code: '123456' } } }).
oauth
oauthState() (public) · oauthCallback({ body }) (public, login) · linkProvider({ body }) · unlinkProvider({ body }) · getProviders()
users
updateProfile({ body })
tenant
All scoped under /tenants/:tenantId/... — params.tenantId is required:
getTenantProfile({ params }) · updateTenantProfile({ params, body }) · createInvite({ params, body }) · listInvites({ params, query? }) · acceptInvite({ params, body }) · declineInvite({ params, body }) · revokeInvite({ params, body }) · listMembers({ params, query? }) · updateMember({ params, body }) · removeMember({ params, body })
await client.tenant.listMembers({ params: { tenantId: 'org-123' } })
await client.tenant.createInvite({
params: { tenantId: 'org-123' },
body: { inviteeEmail: '[email protected]', roles: ['member'], authPerms: ['tenant.member.view'] }
})client.api — authenticated requests to your own APIs
The typed groups above only cover Auth Craft's routes. Your app still has its
own backend (commerce, console, billing, …) that the same logged-in user must
call. client.api is that escape hatch: a raw HTTP transport that rides the
same Worker-isolated session — tokens stay out of JS, refresh is shared, and
you get a deserialized Result<T> back.
// any URL you build — points at YOUR backend, not the auth server
const r = await client.api.post<{ items: Product[] }>(
'https://api.example.com/core/products',
{ name: 'Tee', price: 1900 }
)
if (r.ok) r.data.items // typed by the <T> you supply
await client.api.get<Plan>('https://api.example.com/core/plans/current')
await client.api.delete('https://api.example.com/core/team/members/remove', { userId })interface ApiTransport {
get<T = unknown>(url: string, options?): Promise<Result<T>>
post<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
put<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
patch<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
delete<T = unknown>(url: string, body?: unknown, options?): Promise<Result<T>>
}- URLs pass through as-is — build absolute URLs to whichever backend you
target. (Unlike the typed groups,
apidoes not prependbaseUrl.) - You supply the type
<T>—apiis unaware of your app's contract. For Auth Craft's own routes, prefer the typed groups, which can't drift. - Throws if accessed before
init().
The escape hatch is deliberately outside the contract: your app's routes are not Auth Craft's concern, so they don't belong in
@auth-craft/api-contract.apigives you the authenticated session; you own the routes.
@auth-craft/client/core — per-scope isolation
The root export (createAuthCraftClient) wires every group, so it can't be
tree-shaken per scope. A web app is almost always one scope (storefront =
customer, console = tenant, admin = system). For those, import the scope-agnostic
core and wire only the groups you need — other scopes' code then stays out of
your bundle and your dev call surface:
import { createClientCore } from '@auth-craft/client/core'
import { authContract } from '@auth-craft/api-contract/auth'
const core = await createClientCore({
baseUrl, strategy: 'cookie-auth', allowedDomains, context: 'customer'
})
const auth = core.group(authContract) // typed, drift-proof
await core.api.get('https://api.example.com/core/products') // your own backendcore.group(contract) attaches a typed client for one group; core.api,
onAuthStateChanged, context, init()/destroy() work exactly as on the full
client. A tenant console just wires more groups (@auth-craft/api-contract/tenant).
Verified: a customer app built this way contains zero tenant/mfa/oauth route
code. "Not in the bundle" ≠ "not on disk" — the unused group files still live in
node_modules/@auth-craft/api-contract, just unreferenced. That's isolation at
the call surface, which is the point.
Keeping it enforced (two guards)
Library guard (always on): a test in this package fails if the core (
src/core) ever imports a contract group or the api-contract root — so the isolation can't silently rot for downstream apps.App guard (optional, your linter): stop an app importing a scope it doesn't own.
ESLint — a helper ships here:
// app/eslint.config.mjs import tseslint from 'typescript-eslint' import { authCraftIsolation } from '@auth-craft/client/eslint' export default tseslint.config( authCraftIsolation({ allow: ['auth', 'password', 'verification', 'mfa', 'oauth', 'session', 'users'] }) )Omit a group (e.g.
tenant) and importing it — or the all-groups root@auth-craft/client, or the api-contract root — becomes an error.Biome — no shipped preset (Biome config is JSON, not composable), but the same intent is
noRestrictedImportsinbiome.json:{ "linter": { "rules": { "nursery": { "noRestrictedImports": { "level": "error", "options": { "paths": { "@auth-craft/client": "Use @auth-craft/client/core + per-group subpaths; the all-groups root can't be tree-shaken.", "@auth-craft/api-contract": "Import a focused group (e.g. /auth), not the root.", "@auth-craft/api-contract/tenant": "This app's scope does not include 'tenant'." } } } } } } }(Rule placement —
nurseryvs stable — follows your Biome version.)Neither linter? No guard is strictly required: an app that never imports
@auth-craft/api-contract/tenantsimply doesn't have it; the guards only catch accidental imports.
Login: handling challenge, MFA & verification
A login call (authenticate, completeChallenge, oauthCallback) resolves to a
discriminated union — tokens, a credential challenge, an MFA requirement, or
an identity-verification requirement. Use the exported type guards to branch:
import {
isAuthTokens,
isChallengeRequired,
isMfaRequired,
isVerificationRequired,
} from '@auth-craft/api-contract'
const r = await client.auth.authenticate({
body: { identityType: 'email', identityValue: '[email protected]', credentialType: 'otp' }
})
if (!r.ok) { /* real error */ return }
if (isChallengeRequired(r.data)) {
// e.g. emailed OTP — collect the code, then:
const done = await client.auth.completeChallenge({
body: { challengeId: r.data.challenge.data.challengeId as string, code: userCode }
})
} else if (isMfaRequired(r.data)) {
// collect MFA code for one of r.data.mfa.methods, then:
const done = await client.auth.completeMfaChallenge({
body: { challengeId: r.data.mfa.challengeId, method: r.data.mfa.methods[0], code: userCode }
})
} else if (isVerificationRequired(r.data)) {
// credentials were correct but the login identity (email) is not verified yet.
// r.data.verification has { challengeId, expiresAt (ISO), identityValue, ... }.
// Prompt the user to enter the emailed code, then verify it:
const done = await client.verification.verifyCode({
body: { challengeId: r.data.verification.challengeId, code: userCode }
})
// expired or lost the code? client.verification.resendVerification(...) issues a new one.
} else if (isAuthTokens(r.data)) {
// session established
}Scopes (customer / tenant / system)
Set context so the backend reads the right refresh-token cookie / scope:
const adminClient = createAuthCraftClient({ /* ... */, context: 'system' })This stamps X-Auth-Context: system on every request. (In a deployment with the
Cloudflare BFF gateway, the gateway is the source of truth for scope; the header
is the local-dev convention.)
Lifecycle
await client.init() // boot Worker; idempotent enough to call once at startup
client.destroy() // terminate the Worker and clear in-memory state
client.getTransport() // advanced: the raw FetchGuard transport handle
createAuthCraftClient(config)returns the client without initializing — callawait client.init()yourself. (createClientCore(config)from@auth-craft/client/coreinitializes for you and resolves a ready core.)
Why it can't drift
| Concern | Guaranteed by |
|---|---|
| Path / path params | shared contract.path; a CI test diffs every contract path against the live route registry |
| HTTP method | same registry diff (method + path) |
| Request body / query | InferInput of the contract schema — wrong/missing fields are a compile error |
| Response shape | InferOutput of the contract response schema; a CI test fails if a data endpoint lacks one |
| Method coverage | methods are generated from the contract group (buildClient iterates its keys) — a new endpoint appears automatically; there is no hand-maintained list to fall behind |
| Server reads the same shape | the backend route's ctx.body/query/params are typed from the same contract schema; renaming a field breaks the client call site and the route handler in one tsc run |
| Per-method transform bugs | there are none — the envelope→Result<T> step lives once in the transport |
Migration from @auth-craft/frontend-sdk
The old @auth-craft/frontend-sdk was hand-written (per-method paths/types), which
let request/response shapes drift from the server. This client supersedes it: the
auth methods are now generated from the contract (drift-proof), and raw
authenticated HTTP moves to client.api.
The package name and factory change, and every auth call uses the uniform
{ body / params / query } shape.
Setup
// OLD
import { createAuthCraft } from '@auth-craft/frontend-sdk'
const client = createAuthCraft({ baseUrl, strategy, allowedDomains })
await client.init()
// NEW
import { createAuthCraftClient } from '@auth-craft/client'
const client = createAuthCraftClient({ baseUrl, strategy, allowedDomains })
await client.init()Call-site changes (the important part)
| Old | New |
|---|---|
| client.auth.authenticate(req) | client.auth.authenticate({ body: req }) |
| client.auth.me() | client.auth.me() (unchanged) |
| client.password.change(req) | client.password.changePassword({ body: req }) |
| client.password.forgot(req) | client.password.forgotPassword({ body: req }) |
| client.password.reset(req) | client.password.resetPassword({ body: req }) |
| client.verification.verify(req) | client.verification.verifyCode({ body: req }) |
| client.verification.resend(req) | client.verification.resendVerification({ body: req }) |
| client.session.list(includeRevoked) | client.session.listSessions({ query: { includeRevoked: 'true' } }) |
| client.session.delete(req) | client.session.deleteSession({ body: req }) |
| client.session.revoke(req) | client.session.revokeToken({ body: req }) |
| client.mfa.setup(req) | client.mfa.setupAuthenticator({ body: req }) |
| client.mfa.verify({ method, code }) | client.mfa.verifyAuthenticator({ body: { method, data: { code } } }) ⚠️ code in data |
| client.mfa.list(opts) | client.mfa.listAuthenticators({ query: opts }) |
| client.mfa.delete({ method, id }) | client.mfa.removeAuthenticator({ body: { method, authenticatorId } }) ⚠️ authenticatorId |
| client.mfa.updateSettings(req) | client.mfa.updateMfaSettings({ body: req }) |
| client.oauth.callback(req) | client.oauth.oauthCallback({ body: req }) |
| client.oauth.link(req) | client.oauth.linkProvider({ body: req }) ⚠️ no accessToken field |
| client.oauth.unlink(req) | client.oauth.unlinkProvider({ body: req }) |
| client.oauth.listProviders() | client.oauth.getProviders() |
| (none) | client.oauth.oauthState() — new, required for the CSRF redirect flow |
| client.user.updateProfile(req) | client.users.updateProfile({ body: req }) |
| client.tenant.createInvite(req) | client.tenant.createInvite({ params: { tenantId }, body }) ⚠️ tenantId in path |
| client.tenant.listMembers() | client.tenant.listMembers({ params: { tenantId } }) ⚠️ tenantId in path |
| client.tenant.getProfile() | client.tenant.getTenantProfile({ params: { tenantId } }) |
| client.api.get/post(...) (raw) | client.api.get/post(...) — unchanged surface, but on client.api (the old SDK exposed these on the root client) |
| client.tenant.checkPendingInvites(req) | not on the typed tenant group (no public auth-route). If your app exposes it on its own API, call it via client.api.post('<your-url>', req). |
Behaviour changes you must be aware of
- Tenant routes are path-scoped. Every tenant method now requires
params.tenantId. The old client called/tenant/...(no id) and 404'd. - MFA codes go in
data, and the field isauthenticatorId(notid). - OAuth never accepts
accessToken— the backend rejects it (token substitution). Usecodeorcredential. For the redirect/code flow, calloauth.oauthState()first and forward the returnedstateto the provider. - Permissions are string arrays (
roles,authPerms), not numeric masks. - Login returns a union — branch with
isAuthTokens/isChallengeRequired/isMfaRequired/isVerificationRequired(see above). The old client returned ad-hoc shapes per path. refreshis automatic — remove any manual refresh calls.- Raw authenticated requests moved to
client.api. The old SDK exposedclient.get/post/put/patch/delete(andgetFetchGuard()) on the root client for hitting your own backend. These now live onclient.apiwith the same signatures (Result<T>in,Result<T>out) — just changeclient.post(...)→client.api.post(...). Auth-route calls should move to the typed groups instead. onAuthStateChangedis unchanged — still a config callback, same{ authenticated, user?, expiresAt? }payload; wire your auto-logout there.
Errors
Both old and new return Result<T> from ts-micro-result, so if (r.ok) { r.data } else { r.errors }
is unchanged. The new client forwards the backend's error codes verbatim.
