@molecule/app-auth
Core interface · auth · App (browser) · v1.5.7 · Apache-2.0
Client-side authentication interface for molecule.dev
npm install @molecule/app-authHow it works
@molecule/app-auth is the auth core interface on the app (browser) side: the API your app calls, with no vendor inside.
Bond a provider to choose the implementation.
import { createJWTAuthClient, setClient } from '@molecule/app-auth'
const client = createJWTAuthClient({ baseURL: '/api' })
setClient(client)
await client.initialize() // restore the session from the httpOnly cookie
// anywhere in the app
import { getUser, isAuthenticated, login, logout } from '@molecule/app-auth'
await login({ email, password })
if (isAuthenticated()) console.log(getUser()?.email)Works with: @molecule/app-bond, @molecule/app-i18n, @molecule/app-logger
Reference
Client-side authentication interface for molecule.dev.
Provides a unified auth API that works across different auth strategies
(JWT, session, OAuth, etc.): createJWTAuthClient builds a fetch-based
client; setClient/getClient bond it; login/logout/register/
isAuthenticated/getUser delegate to the bonded client. Framework
bindings (e.g. AuthProvider/useAuth in @molecule/app-react) wrap the
same client.
In a scaffolded app, wire it by hand — the sandbox cannot run mlcl add.
The package is already installed; create the client once at startup with
setClient(createJWTAuthClient({ baseURL, authScheme: 'Token', storage: createBrowserStorageAdapter() })), or createDefaultAuthClient(authConfig)
from @molecule/app-bonds-default-react (which also registers
whenRestored() as the HTTP gate), call initialize(), and read the session
through MoleculeProvider/useAuth() in a React app.
initialize() restores the session BEFORE the first render, and no
hand-rolled token store may replace it. With the token AND user in the
store the restore is synchronous and costs no request; with an httpOnly
session cookie the API sets at login it restores through
currentUserEndpoint (default /users/me). AuthClientConfig.storage is
the config field that decides which route a reload takes. A store written
beside the client answers requests before its own restore settles: the first
authenticated page renders signed-out, and its 401 signs the user out for
good.
Calling initialize() is optional — initialization is lazy and idempotent.
The first read of the client (getState(), subscribe()/onAuthChange(), a
useAuth() mount, isAuthenticated(), getUser()) starts the same one restore,
and an explicit initialize() returns the same promise, so a second call asks the
server nothing. Keep the template's explicit call (it makes the first render
deterministic); an entry file that drops it still restores instead of reporting
'loading' forever (xr-molecule-flash-m192: a main.tsx rewrite dropped the line
and 18 tests timed out on pages that never left their loading branch).
Never fetch viewer-dependent data before the restore settles. A
component effect that runs on mount fires while initialize() is still
restoring, so its request leaves without the Authorization header and the
server answers for an anonymous viewer — a followed user renders "Follow", a
saved item renders unsaved. On localhost the restore usually wins this race;
over the network it does not, so the bug appears only on the deployed URL.
Gate the fetch on the auth state being settled (state.initialized), or let
the HTTP client do it: @molecule/app-bonds-default-react registers
whenRestored() as the gate so every request waits for the restore.
Waiting for the restore does NOT attach its token: a separately created HTTP
client needs
createDefaultHttpClientWithAuthBearer({ baseURL, getToken: () => authClient.getAccessToken(), authScheme })
from @molecule/app-bonds-default-react plus
setAuthGate(() => authClient.whenRestored()) from @molecule/app-http — or
the auth client built with createDefaultAuthClientWithHttpSync, which
mirrors the token into the bonded client.
getState().status is the session state to render, in one value.
'loading' (no decision yet) → 'authenticated' / 'unauthenticated', and
'unavailable' when a stored session exists but the server could not confirm it.
A stored token AND a stored user make the FIRST render 'authenticated':
initialize() sets that state synchronously, before any request, and every
verification settles behind that render. Never hold the first paint on
validateSession() or a /me request of your own — a protected page then pays
an API round trip on every navigation (xr-molecule-flash-m177: ~30 full page
loads in one pagination spec; its four tests sat at 15.0–15.9 s of a 15 s
budget and the grader timed two out). Route guards read this same status:
wait only while it is 'loading'.
validateSession() is the background re-check that produces the last two: call
it after the first render — a 401 or 403 clears the token ('unauthenticated'),
while a network failure, a 5xx, or a 404 from a wrong currentUserEndpoint
KEEPS the token and the user ('unavailable', retryable; the 404 error names
currentUserEndpoint). Do not build a
validating/unavailable flag beside the client: a second module-level state
plus its own listener set is a second session store, and it drifts from the
client's.
Only a 401/403 ENDS a session; no response at all never does. Every path that
clears the stored session (validateSession, the cookie restore, the initial
refresh, the background refresh timer) keys on the server REJECTING the
credential. An aborted request, a dropped connection, or a 5xx arrives with no
HTTP status (or status: 0 from @molecule/app-http) and says nothing about the
session: the token and user are kept and the state becomes 'unavailable' so a
later load or retry can restore it. An app's own code must use the same test —
isAuthFailure(err) from @molecule/app-http (401/403 only) — never
err.status < 500, which is true for an aborted navigation request and signs the
user out on a route change.
autoRefresh does nothing without a refresh token — and must never log the
user out. The client schedules a refresh only when refresh() could actually
succeed, i.e. when a refresh token is stored. An API that issues none (the
@molecule/api-resource-user model: login/register return { accessToken, user } and the durable session is an httpOnly cookie the SERVER refreshes)
therefore schedules nothing: the session stays until the API answers 401
(validateSession() clears it) or the user logs out. autoRefresh: true
against such an API is harmless but pointless — leave it false.
Seeding a session from an exchange the app runs itself: setSession(). When an OAuth
redirect or the app's own sign-in endpoint has already issued the token and returned the
user, setSession({ accessToken, user }) stores both and flips the client to
authenticated in one update, so every hook reading the shared client is signed in at once
(a form's own sign-in code wrote the token and the user with setAccessToken/setUser,
which change their field only — the UI stayed signed out and nine browser assertions timed
out, xr-molecule-flash-m178). setAccessToken() and setUser() are field setters, not a
sign-in: neither sets authenticated.
A custom user profile is typed once and used everywhere: AuthClientConfig<MyUser>,
createJWTAuthClient<MyUser> and useAuth<MyUser>(). The config's generic makes
mapAuthResponse return AuthResult<MyUser>, so a mapper that omits a required custom
field fails at the factory call instead of shipping a signed-in page whose navbar reads
undefined (xr-molecule-flash-m255: the mapper kept id/name/avatar but dropped
username, and the signed-in controls never rendered although login succeeded). Keep the
wire user's fields and ADD the base identity fields; do not rebuild the profile field by
field, and never hide a mismatch with as unknown as MyUser:
import type { AuthClientConfig, UserProfile } from '@molecule/app-auth'
import { createJWTAuthClient } from '@molecule/app-auth'
interface MyUser extends UserProfile {
username: string
image: string
}
const config: AuthClientConfig<MyUser> = {
mapAuthResponse: (data) => {
const { user: wire } = data as { user: { token: string } & Omit<MyUser, 'id'> }
return {
accessToken: wire.token,
user: {
// Every wire field the pages read (`username`, `image`, …) survives…
...wire,
// …plus the base fields the client derives its identity from.
id: wire.username,
name: wire.name ?? wire.username,
avatar: wire.image,
},
}
},
}
const client = createJWTAuthClient<MyUser>(config)
// React: `useAuth<MyUser>()` reads the same profile.
A background verification that returns an unchanged user changes nothing. When
validateSession() (or a reload's restore) answers with the same profile data the client
already holds, the client keeps the SAME user object and does not notify subscribers — so an
effect seeded from user does not re-run, and a form it fills keeps what has been typed.
Seed such a form once (on its first load), never on every user identity.
A token another tool reads at its own key is a CONFIG field, not a mirror.
An e2e suite or host page that expects localStorage.jwtToken (the RealWorld
specs do) is satisfied by tokenKey: 'jwtToken' — in a browser the client's
storage default is already createBrowserStorageAdapter(), so with an explicit
'memory' store you must also pass
storage: createBrowserStorageAdapter(). Writing the token into that key from a
subscription beside the client is a second store. In React, useAuth() from
@molecule/app-react returns the client's state (with status), user and
isAuthenticated; the token is useAuthClient().getAccessToken().
A store that registers its own gate must exempt the restore's own request.
This client restores with fetch, so the gate registered over it never holds
the restore. A store that restores THROUGH the bonded HTTP client and
registers that restore as the gate (setAuthGate(() => store.whenSettled()))
waits on itself unless the restore's request passes skipAuthGate: true —
otherwise the gate holds the very request it is waiting for, and every
request it holds stalls until the gate times out. See
RequestConfig.skipAuthGate in @molecule/app-http.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { createJWTAuthClient, setClient } from '@molecule/app-auth'
const client = createJWTAuthClient({ baseURL: '/api' })
setClient(client)
await client.initialize() // restore the session from the httpOnly cookie
// anywhere in the app
import { getUser, isAuthenticated, login, logout } from '@molecule/app-auth'
await login({ email, password })
if (isAuthenticated()) console.log(getUser()?.email)
An API that wraps the payload and registers extra fields (RealWorld): type those
fields with the factory's second generic, and requestBody receives exactly what
the form passed — { user: { username, email, password } } goes out as-is.
import type { UserProfile } from '@molecule/app-auth'
import { createJWTAuthClient } from '@molecule/app-auth'
const realWorldAuth = createJWTAuthClient<UserProfile, { username: string }>({
baseURL: '/api',
authScheme: 'Token',
requestBody: (_kind, payload) => ({ user: payload }),
mapAuthResponse: (data) => {
const { user } = data as { user: { token: string; username: string; email: string } }
return { accessToken: user.token, user: { id: user.username, email: user.email } }
},
})
await realWorldAuth.register({ username: 'alice', email: 'alice@example.com', password: 's3cret' })
Type
core
Installation
npm install @molecule/app-auth @molecule/app-bond @molecule/app-i18n @molecule/app-logger
API
Interfaces
BrowserStorageAdapterOptions
How createBrowserStorageAdapter picks the store it writes to.
interface BrowserStorageAdapterOptions {
/**
* The Web Storage area to persist to (default `'local'`).
*
* `'session'` is the narrower choice: a reload keeps the session, closing the
* tab ends it. Pick it when the app only has to survive navigations within one
* session — which is all a `page.reload()` asks for.
*/
kind?: BrowserStorageKind
/**
* A store to use instead of resolving `window.localStorage` /
* `window.sessionStorage` (a test double, a namespaced wrapper, or the
* `Storage` object another library exposes). Anything with `getItem`/
* `setItem`/`removeItem` does.
*/
storage?: StorageAdapter
}
UserProfile
User profile information.
interface UserProfile {
/**
* Unique user identifier.
*/
id: string
/**
* User's email address.
*/
email?: string
/**
* User's display name.
*/
name?: string
/**
* User's avatar/profile image URL.
*/
avatar?: string
/**
* User's roles or permissions.
*/
roles?: string[]
/**
* Additional metadata.
*/
metadata?: Record<string, unknown>
}
LoginCredentials
Email/password login credentials with optional "remember me" flag.
interface LoginCredentials {
/**
* Email or username.
*/
email: string
/**
* Password.
*/
password: string
/**
* Whether to remember the session.
*/
remember?: boolean
/**
* Time-based one-time password (TOTP) from the user's authenticator app.
* Required when the account has two-factor auth enabled — the server
* answers `{ twoFactorRequired: true }` (HTTP 206) until a valid token
* is supplied.
*/
twoFactorToken?: string
}
AuthResult
Successful authentication result containing the user, access/refresh tokens, and expiration.
interface AuthResult<T = UserProfile> {
/**
* Authenticated user.
*/
user: T
/**
* Access token (for token-based auth).
*/
accessToken?: string
/**
* Refresh token (for token-based auth).
*/
refreshToken?: string
/**
* Token expiration time (Unix timestamp).
*/
expiresAt?: number
/**
* Set (with HTTP 206) when the account has two-factor auth enabled and the
* login did not include a valid `twoFactorToken`. No tokens or user are
* issued until login is retried with the TOTP — callers must check this
* flag BEFORE treating the result as a successful authentication.
*/
twoFactorRequired?: boolean
}
PasswordResetRequest
Password reset request data.
interface PasswordResetRequest {
/**
* Email address.
*/
email: string
}
PasswordResetConfirm
Password reset confirmation data.
interface PasswordResetConfirm {
/**
* Reset token.
*/
token: string
/**
* New password.
*/
password: string
}
AuthState
Reactive authentication state snapshot (initialized, authenticated, user, loading, and error).
interface AuthState<T = UserProfile> {
/**
* The session's state as one value — render from this instead of combining
* `initialized`/`authenticated`/`loading`. See `AuthStatus`.
*/
status: AuthStatus
/**
* Whether auth state has been initialized.
*/
initialized: boolean
/**
* Whether the user is authenticated.
*/
authenticated: boolean
/**
* Current user (if authenticated).
*/
user: T | null
/**
* Whether an auth operation is in progress.
*/
loading: boolean
/**
* Last auth error (if any).
*/
error: string | null
}
AuthSessionSeed
A complete session an exchange outside the client has already established — an OAuth
code→token redirect, or the app's own sign-in endpoint. AuthClient.setSession adopts
it in one update: the token and the user are stored and the client becomes authenticated.
interface AuthSessionSeed<T = UserProfile> {
/** The access token the exchange issued. */
accessToken: string
/** The profile that token belongs to, as the API returned it. */
user: T
/** A refresh token, when the exchange issued one; omitted leaves the stored one untouched. */
refreshToken?: string | null
}
AuthClient
Auth client interface that all auth bond packages must implement. Provides login/logout/register flows, token management, profile updates, and auth state subscription.
interface AuthClient<
T = UserProfile,
TRegister extends Record<string, unknown> = Record<never, never>,
> {
/**
* Returns the current authentication state snapshot.
*
* READING the state starts the client's one session restore when nobody has called
* `initialize` yet — lazily and exactly once, sharing the promise an explicit
* `initialize()` returns. A hook (`useAuth()`), a subscriber, `isAuthenticated()` and
* `getUser()` are reads too, so a client whose entry file dropped the template's
* `authClient.initialize()` line still restores instead of reporting `'loading'`
* forever.
*/
getState(): AuthState<T>
/**
* Returns whether the user is currently authenticated.
*/
isAuthenticated(): boolean
/**
* Gets the current user.
*/
getUser(): T | null
/**
* Updates the cached user object (state + persistent storage) without
* hitting the network. Intended for local refreshes after a per-app
* mutation (e.g., the user just PATCHed their own profile and the
* server returned the canonical row). Does NOT change tokens.
*
* This does not sign the client in: `authenticated` and `status` are
* unchanged, so an anonymous client stays anonymous. Use
* `login` / `register` for a credential exchange, or
* `setSession` when the token was issued outside the client.
*/
setUser(user: T | null): void
/**
* Gets the current access token.
*/
getAccessToken(): string | null
/**
* Stores the access token in the configured token storage adapter (in a
* browser, Web Storage; `'memory'` where no browser store exists). Use this
* to seed the token after an out-of-band exchange (e.g. the OAuth
* code→token redirect) instead of writing to `localStorage` directly,
* which would violate the storage contract and make a stored bearer token
* JS-readable (XSS-exfiltratable). Pass `null` to clear it.
*
* This changes token storage only; it does not sign the client in
* (`authenticated` and `status` are unchanged). Use `login` /
* `register` for a credential exchange, or call `setSession`
* with the token and the user to establish an already-issued session.
*/
setAccessToken(token: string | null): void
/**
* Adopts a session an out-of-band exchange already established: stores the
* access token (and the refresh token when given), caches the user, and
* sets the COMPLETE state — `initialized`, `authenticated`, `status:
* 'authenticated'`, no error — in one update, with one subscriber
* notification. Every hook reading the shared client sees the user as
* signed in immediately.
*
* This is the way to seed an OAuth/custom sign-in. Calling
* `setAccessToken` and `setUser` instead does NOT sign the
* client in.
*/
setSession(session: AuthSessionSeed<T>): void
/**
* Gets the refresh token.
*/
getRefreshToken(): string | null
/**
* Logs in with credentials.
*/
login(credentials: LoginCredentials): Promise<AuthResult<T>>
/**
* Logs out the current user.
*/
logout(): Promise<void>
/**
* Registers a new user. Accepts the base fields plus the app's own fields
* (`TRegister`) at the top level — see `RegisterData`.
*/
register(data: RegisterData<TRegister>): Promise<AuthResult<T>>
/**
* Refreshes the access token.
*/
refresh(): Promise<AuthResult<T>>
/**
* Requests a password reset.
*/
requestPasswordReset(data: PasswordResetRequest): Promise<void>
/**
* Confirms a password reset.
*/
confirmPasswordReset(data: PasswordResetConfirm): Promise<void>
/**
* Updates the current user's profile.
*/
updateProfile(data: Partial<T>): Promise<T>
/**
* Changes the current user's password.
*/
changePassword(oldPassword: string, newPassword: string): Promise<void>
/**
* Initializes auth state (e.g., from stored tokens).
*
* LAZY and IDEMPOTENT: the first read of the state (`getState`,
* `subscribe`, `useAuth()`) starts this same restore when nobody has called
* `initialize()`, and every later call shares the one promise instead of sending a
* second request. An app that keeps the template's explicit call behaves exactly as
* before; an app that loses that line still restores its session.
*
* With a stored token AND a stored user it marks the session `'authenticated'`
* SYNCHRONOUSLY — before its first `await` — so the first render is the
* signed-in UI and no page waits on a verification round trip. Any check
* (a refresh, the cookie-restore probe) settles behind that render, and only an
* explicit 401/403 ends the session; a network/5xx/404 failure keeps it and
* reports `'unavailable'`. With no stored user the client stays `'loading'`
* until the cookie restore decides.
*
* Do not put this call (or a `/me` request of your own) in front of the first
* render, and do not render a loading screen over a session it just restored:
* `whenRestored()` already resolves as soon as the restore has settled, and the
* HTTP gate built on it holds requests for exactly that window.
*/
initialize(): Promise<void>
/**
* Re-checks a restored session against the server, in the background — the
* boot-time validation an app with a stored token needs when a revoked or
* expired token must sign the user out. Call it AFTER the first render
* (`initialize()` restores synchronously from storage; this costs one request).
*
* - The server confirms the user: the session is refreshed and `AuthState.status`
* stays `'authenticated'`.
* - The server answers 401 or 403: the token is dead — the stored session is
* cleared and the status becomes `'unauthenticated'`.
* - The server cannot confirm the session — a network failure, a 5xx, or a 404
* from a misconfigured `AuthClientConfig.currentUserEndpoint`: the
* session (token AND user) is KEPT and the status becomes `'unavailable'`, so
* the app can retry instead of signing the user out over a bad connection or a
* route that does not exist. The 404 error names `currentUserEndpoint`.
*
* Do not hand-write this beside the client: a parallel token/user store for a
* "validating"/"unavailable" flag is the race this method removes.
*
* Optional: a provider with no restorable session has nothing to validate.
*/
validateSession?(): Promise<void>
/**
* Resolves when the session restore started by `initialize()` has settled — the point
* after which the client knows whether this visitor is signed in.
*
* Resolves IMMEDIATELY when no restore is in flight: before `initialize()` is ever
* called, and after it has finished. A restore that failed resolves too (it settled the
* question), so an HTTP client can await this before every request without risking one
* held forever. That is what makes the fetch-before-restore race impossible: a page that
* fetches viewer-dependent data on mount waits here instead of going out anonymously
* (see `setAuthGate` in `@molecule/app-http`).
*
* Optional: a provider that restores no session has nothing to wait for. Without it, an
* HTTP client bonded to that provider sends first-paint requests unauthenticated.
*
* A provider whose restore sends a request THROUGH the gated HTTP client must send that
* request with `skipAuthGate: true` (`RequestConfig` in `@molecule/app-http`): the gate
* waits on this promise, so holding the restore's own request makes the gate wait on
* itself and stalls every request it holds until it times out. `createJWTAuthClient`'s
* restore does not need it — it uses `fetch`, not the bonded client.
*/
whenRestored?(): Promise<void>
/**
* Subscribes to auth state changes.
*/
subscribe(callback: (state: AuthState<T>) => void): () => void
/**
// … truncated — the full declaration is in node_modules/@molecule/app-auth/dist/types.d.ts
StorageAdapter
Minimal key-value storage adapter used by the auth client for persisting tokens and user data.
interface StorageAdapter {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
AuthClientConfig
Configuration options for creating an auth client, including API endpoints, storage strategy, token refresh, and OAuth providers.
Type it with the app's user — AuthClientConfig<MyUser> — the same type
createJWTAuthClient<MyUser> returns and useAuth<MyUser>() reads. That
makes mapAuthResponse return AuthResult<MyUser>, so a mapper that
omits a required custom field is a type error instead of a signed-in page
reading undefined (xr-molecule-flash-m255). Keep every wire field the pages
read (spread the wire user and add id/name/avatar); mapping a field to a
different name does not preserve the original, and a as unknown as MyUser
assertion hides the loss. See the module docs for the wire-preserving
adapter.
interface AuthClientConfig<T extends UserProfile = UserProfile> {
/**
* API base URL.
*/
baseURL?: string
/**
* Login endpoint.
*/
loginEndpoint?: string
/**
* Logout endpoint.
*/
logoutEndpoint?: string
/**
* Register endpoint.
*/
registerEndpoint?: string
/**
* Refresh endpoint.
*/
refreshEndpoint?: string
/**
* User profile endpoint.
*/
profileEndpoint?: string
/**
* The API route that returns the signed-in user (default `/users/me`). Called
* with credentials by `initialize()` to re-establish the session after a full
* page load when the token did not survive the reload itself — i.e. when
* `storage` is `'memory'` and the API sets an httpOnly session cookie — and by
* `AuthClient.validateSession` on every reload to confirm a stored token
* is still valid. Must return the user (as `user` or `props`).
*
* Set it whenever the app serves its own current-user route: a wrong value
* answers 404, which `validateSession` reports as `'unavailable'` and keeps the
* stored token instead of signing the user out — but the session then stays
* unconfirmed.
*/
currentUserEndpoint?: string
/**
* Storage key prefix.
*/
storagePrefix?: string
/**
* The EXACT `StorageAdapter` key the access token is written under (default
* `${storagePrefix}accessToken`).
*
* Set it when something outside this package reads the token at its own key —
* an e2e suite's contract (RealWorld's specs read `localStorage.jwtToken`), a
* second library, a host page — instead of mirroring the token into
* `localStorage` beside the client. The refresh token and the cached user keep
* the prefix.
*
* In a browser the client's storage default is `createBrowserStorageAdapter`,
* so `tokenKey` alone writes the token to exactly that key and a reload keeps
* the session. An EXPLICIT `storage: 'memory'` beside it warns at construction:
* the value then lives in a process-local Map nothing outside this client can
* read, and a reload signs the user out.
*/
tokenKey?: string
/**
* Scheme of the `Authorization` header the access token is sent with:
* `Bearer` (default), or e.g. `Token` for an API that reads
* `Authorization: Token <jwt>`.
*/
authScheme?: string
/**
* Shape the login/register request body for an API that expects another
* envelope (default: the payload as given). `register()` hands this callback
* exactly what the caller passed — including the app's own fields, typed
* through `createJWTAuthClient<UserProfile, { username: string }>` — so the
* body can be sent as-is under the envelope the API wants.
*
* RealWorld, for example, nests the payload under `user` and registers with
* `{ user: { username, email, password } }`:
*
* ```typescript
* const client = createJWTAuthClient<UserProfile, { username: string }>({
* requestBody: (kind, payload) => ({ user: payload }),
* // …
* })
* await client.register({ username, email, password })
* ```
*
* A caller that keeps the untyped payload can map the base fields instead:
* `(kind, p) => ({ user: { username: p.name, email: p.email, password: p.password } })`
* with `register({ email, password, name: username })`.
*
* @param kind - Which request.
* @param payload - The credentials or registration data.
* @returns The JSON body to send.
*/
requestBody?: (kind: 'login' | 'register', payload: Record<string, unknown>) => unknown
/**
* Turn an answer that carries a user into the client's
* `{ accessToken, refreshToken?, user }` (default: the answer as given).
* RealWorld, for example: `(data) => ({ accessToken: data.user.token, user: data.user })`.
*
* It is the ONE user-shape adapter, and the client applies it on EVERY path
* that adopts a user from an API answer — `login()`, `register()`,
* `validateSession()`, the cookie restore `initialize()`/`refresh()` run, the
* token-refresh answer, and `updateProfile()`. Those answers carry the raw wire
* user (a current-user route typically answers a bare `{ user }`), so applying
* the same mapping is what keeps the adapted profile after a reload instead of
* replacing it with the unadapted one (xr-molecule-flash-m184). A mapper that
* cannot read a given answer (it throws, or returns no `user`) leaves that
* answer's user as it came.
*
* It is NOT applied to `setSession()` or `setUser()`: those receive a profile
* the caller already built in this client's shape, not a wire answer.
*
* @param data - The parsed response body.
* @returns The auth result.
*/
mapAuthResponse?: (data: unknown) => AuthResult<T>
/**
* Where the access/refresh token and the cached user are kept — the field that
* decides whether a page reload keeps the user signed in.
*
* **In a browser the default is `createBrowserStorageAdapter()` (Web Storage)**,
* so a reload keeps the session with no wiring of its own; outside a browser
* (SSR, a Node test) the default is `'memory'`. Pass `'memory'` EXPLICITLY when
* the API sets an httpOnly session cookie and you do not want a script-readable
* copy: `'memory'` is a process-local store, and `initialize()` then restores
* through `AuthClientConfig.currentUserEndpoint` — the strongest
* configuration, because no script can read the cookie.
*
* **An app whose external contract requires the token at a specific key** — a
* supplied e2e suite (the RealWorld specs read `localStorage.jwtToken` in
* `SELECTORS.md`), a host page, a second library — sets `tokenKey: 'jwtToken'`;
* in a browser that is enough, because the default store is Web Storage. The
* session (token AND user) is then restored synchronously in `initialize()` —
* with no store of your own. `tokenKey` places the access token at that exact
* key; the refresh token and the cached user keep `storagePrefix`.
*
* A bearer token in Web Storage is readable by any script on the origin, so an
* XSS can steal it; see `createBrowserStorageAdapter`'s `@remarks` for the
* trade and for the cookie alternative. Never answer a suite's storage key with a
* second store or a restore script beside this client — that races the client's
* own restore, and either side's 401 signs the user out for good.
*
* Any adapter with `getItem`/`setItem`/`removeItem` works — the methods are
* SYNCHRONOUS, so the async `@molecule/app-storage` provider
* (`getProvider()`, whose `get()` returns a Promise) is NOT one: wrap the
* `Storage` object it was built on (`createBrowserStorageAdapter({ storage })`),
* or store a different way.
*
* @example
* ```typescript
* import { createBrowserStorageAdapter } from '@molecule/app-auth'
*
* // A suite/host page reads the token at its own key (the browser default
* // already stores it; `storage` is shown for an explicit choice):
* storage: createBrowserStorageAdapter()
* tokenKey: 'jwtToken'
* // Only this tab's lifetime, at a narrower exposure than localStorage:
* storage: createBrowserStorageAdapter({ kind: 'session' })
* // An httpOnly cookie session — keep the bearer out of script-reachable storage:
* storage: 'memory'
* ```
*/
storage?: 'memory' | StorageAdapter
/**
* Decide whether a user returned by the cookie-restore probe
* (`currentUserEndpoint`) constitutes a signed-in CLIENT session. Return
* `false` to reject the restore: the client stays unauthenticated and the
* `mol_auth` presence hint is cleared so later loads skip the probe (the
* httpOnly session cookie is untouched and keeps authenticating API calls).
* Use this when the server also issues sessions that must never appear
* signed-in client-side — e.g. an anonymous/guest session whose user row
// … truncated — the full declaration is in node_modules/@molecule/app-auth/dist/types.d.ts
TokenStorage
Internal token storage interface for reading and writing access/refresh tokens and user profile data.
interface TokenStorage {
getAccessToken(): string | null
setAccessToken(token: string | null): void
getRefreshToken(): string | null
setRefreshToken(token: string | null): void
getUser<T = UserProfile>(): T | null
setUser<T = UserProfile>(user: T | null): void
clear(): void
}
Types
BrowserStorageKind
Which Web Storage area createBrowserStorageAdapter persists to:
'local' is window.localStorage (survives a reload AND the tab closing),
'session' is window.sessionStorage (survives a reload, dies with the tab).
type BrowserStorageKind = 'local' | 'session'
RegisterData
New user registration payload: the email/password fields every API takes,
plus TExtra — the app's OWN registration fields, at the top level.
TExtra is what the app's sign-up form collects and its API expects
(username, fullName, …). Give the client the same type and the typed call
passes those fields straight through, so
AuthClientConfig.requestBody receives exactly what the form passed —
no cast, and no stuffing them into name or metadata:
const client = createJWTAuthClient<UserProfile, { username: string }>({ … })
await client.register({ email, password, username }) // `username` is typed
TExtra defaults to no extra fields, so an app that passes name/metadata
(and a requestBody that reads them) keeps type-checking unchanged; only a
client constructed with a second generic accepts (and types) more fields.
type RegisterData<TExtra extends Record<string, unknown> = Record<never, never>> = {
/**
* Email address.
*/
email: string
/**
* Password.
*/
password: string
/**
* Display name.
*/
name?: string
/**
* Additional registration fields.
*/
metadata?: Record<string, unknown>
} & TExtra
AuthStatus
The single value an app renders the session from — the four states every auth
UI needs, derived from AuthState so no caller has to combine
initialized/authenticated/loading/error itself.
'loading': no decision yet (beforeinitialize()settles, or an auth operation is in flight).'authenticated': a session exists.'unauthenticated': the client settled the question and nobody is signed in.'unavailable': a stored session exists but the server could not confirm it (a network or 5xx failure inAuthClient.validateSession); the session is KEPT and the app can say "reconnecting" instead of signing the user out.
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'unavailable'
AuthEvent
Auth event types.
type AuthEvent =
| { type: 'login'; user: UserProfile }
| { type: 'logout' }
| { type: 'register'; user: UserProfile }
| { type: 'refresh' }
| { type: 'error'; error: string }
AuthEventListener
Callback invoked when an authentication event occurs (login, logout, etc.).
type AuthEventListener = (event: AuthEvent) => void
Functions
createBrowserStorageAdapter(options)
The auth client's StorageAdapter over the browser's Web Storage.
The restore it enables costs NO request — never block the first paint on one. With the
token AND the user in the store, initialize() restores the authenticated state synchronously
(whenRestored() is already resolved on the first render); render from it, and validate the
token as a background request — never in front of the first render. A page that waits on its
own /me call pays a round trip on EVERY navigation (xr-molecule-flash-m122: a 1–5 s restore
window, eight waitForURL timeouts).
In a browser this IS the auth client's default AuthClientConfig.storage, so a scaffolded
app keeps its session across a page reload with no extra wiring. Outside a browser (SSR, a Node
test) the client defaults to 'memory' instead, because there is no Web Storage to use. Pass
'memory' explicitly to keep the bearer token out of script-reachable storage when the API sets
an httpOnly session cookie (see the security note below).
import { createJWTAuthClient, createBrowserStorageAdapter } from '@molecule/app-auth'
const authClient = createJWTAuthClient({
baseURL: '/api',
storage: createBrowserStorageAdapter(), // a reload keeps the user signed in
})
The browser client already defaults to it, so a scaffolded app survives a reload out of the box; each of these replaces that default:
storage: 'memory' // memory only: pair it with an httpOnly cookie session
(description truncated — read the full text in node_modules/@molecule/app-auth/dist/browser-storage.d.ts.)
function createBrowserStorageAdapter(options?: BrowserStorageAdapterOptions): StorageAdapter
options— Which Web Storage area to use, or a store to use directly.
Returns: A synchronous StorageAdapter bound to that store.
createJWTAuthClient(config)
Creates a simple JWT-based auth client.
This is a basic implementation that can be extended or replaced with more sophisticated auth providers.
function createJWTAuthClient(config?: AuthClientConfig<T>): AuthClient<T, TRegister>
config— Auth client configuration including endpoints, storage, and refresh settings.
Returns: A fully configured AuthClient instance.
setClient(client)
Registers an auth client as the active singleton. Called by bond packages during application startup.
function setClient(client: AuthClient<T, TRegister>): void
client— The auth client implementation to bond.
getClient()
Retrieves the bonded auth client, throwing if none is configured.
function getClient(): AuthClient<T, TRegister>
Returns: The bonded auth client.
hasClient()
Checks whether an auth client is currently bonded.
function hasClient(): boolean
Returns: true if an auth client is bonded.
createTokenStorage(storage, prefix, tokenKey)
Creates a token storage implementation backed by either in-memory
storage or a custom StorageAdapter.
function createTokenStorage(
storage?: StorageAdapter | 'memory',
prefix?: string,
tokenKey?: string,
): TokenStorage
storage—'memory'for in-memory storage (lost on refresh), or aStorageAdapterfor persistent storage.prefix— Key prefix for storage items (default:'molecule:auth:').tokenKey— Exact key for the access token (default${prefix}accessToken), for a store another tool reads at its own key.
Returns: A TokenStorage instance.
parseJWT(token)
Parses a JWT token payload without verification (base64-decodes the payload segment).
function parseJWT(token: string): T | null
token— The raw JWT string (header.payload.signature).
Returns: The decoded payload object, or null if parsing fails.
isTokenExpired(token, bufferSeconds)
Checks if a JWT token is expired (or will expire within the buffer window).
function isTokenExpired(token: string, bufferSeconds?: number): boolean
token— The raw JWT string.bufferSeconds— Seconds before actual expiry to consider the token expired (default: 0).
Returns: true if the token is expired or lacks an exp claim.
getTokenExpiration(token)
Returns the expiration timestamp of a JWT token in milliseconds.
function getTokenExpiration(token: string): number | null
token— The raw JWT string.
Returns: The expiration time in ms since epoch, or null if the token has no exp claim.
isAuthenticated()
Checks if the current user is authenticated.
function isAuthenticated(): boolean
Returns: true if the user has an active session.
getUser()
Returns the current user profile, or null if not authenticated.
function getUser(): T | null
Returns: The user profile, or null if not authenticated.
login(credentials)
Logs in with the given credentials (email/username + password).
function login(credentials: LoginCredentials): Promise<AuthResult<UserProfile>>
credentials— Email/username and password.
Returns: The auth result containing tokens and user profile.
logout()
Logs out the current user, clearing tokens and session state.
function logout(): Promise<void>
Returns: A promise that resolves when logout completes.
register(data)
Registers a new user account.
function register(data: RegisterData<TExtra>): Promise<AuthResult<UserProfile>>
data— Registration data (email, password, and anyTExtrafields).
Returns: The auth result containing tokens and user profile.
Injection Notes
Requirements
Peer dependencies:
@molecule/app-bond^1.0.1@molecule/app-i18n^1.0.1@molecule/app-logger^1.0.1
Runtime Dependencies
-
@molecule/app-bond -
@molecule/app-i18n -
@molecule/app-logger -
An API with its own auth envelope keeps this client.
requestBodywraps the login/register payload ((kind, p) => ({ user: p })),mapAuthResponsereads the token and user out of the answer ((d) => ({ accessToken: d.user.token, user: d.user })), andauthScheme: 'Token'sendsAuthorization: Token <jwt>.mapAuthResponseis the ONE user-shape adapter: the client applies it to every answer that carries a user (login, register,validateSession(), the cookie restore, a token refresh,updateProfile()), so the adapted profile survives a reload instead of being replaced by the raw wire user. Both are fields of theAuthClientConfigthe client is CONSTRUCTED with (setClient(createJWTAuthClient({ authScheme: 'Token', … }))in the auth bond), not properties of a client that already exists. Do not hand-write an auth client for a spec like RealWorld. Registration fields the base payload lacks (username, …) are the factory's second generic —createJWTAuthClient<UserProfile, { username: string }>(…)— soregister({ username, email, password })type-checks and the body goes out as{ user: { username, email, password } }. Do not pass them asname/metadataor cast the payload. -
Endpoint defaults must match your API's real routes (
/auth/login,/auth/register,/auth/refresh,/users/logout,/users/me, …). Align them viacreateJWTAuthClient({ baseURL, ...endpoints })— never by hand-editing fetch calls in components. -
Client-side auth state is UX only — the server enforces authorization on every request; hiding a screen is not protection.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Logging in with VALID credentials on the real login screen resolves
login()and flips AuthState toauthenticated: truewithgetUser()returning the actual UserProfile — the UI shows that user's ownemail/name/avatar, never a placeholder or a different account. - A WRONG password shows a visible error (AuthState
erroris set and the promise rejects) and establishes NO session:isAuthenticated()stays false and no protected screen becomes reachable. - Registering through the sign-up form calls
register(), creates the account, and lands authenticated (authenticated: true,userpopulated) on the app's post-signup screen — not back on the login form. If the flow shows an email-verification / "check your inbox" step, it must be genuinely backed (this client'sregister()returns an authenticated session directly and exposes no verify method) — never a dead screen the auth contract cannot advance past. - Logout calls
logout()and clears the session (authenticated: false,user: null, tokens cleared); afterward every protected screen redirects back to login. - Visiting a protected route while logged OUT redirects to login (the
guard reads
isAuthenticated()), and after authenticating you land back on the originally-requested screen rather than a generic home. - The session survives a FULL page reload: after login, hard-reload → you
stay signed in (the profile/sign-out UI a signed-in visitor sees is still
there), and you are NOT bounced to login. Which route does it is the app's
AuthClientConfig.storage: in a browser the client defaults tocreateBrowserStorageAdapter(), which keeps the token there; an httpOnly session cookie is restored byinitialize()throughcurrentUserEndpoint(default/users/me). If the app setsstorage: 'memory'explicitly against an API that sets no cookie, this box cannot be checked, and the fix is the config, not a token store written beside the client. - Password reset round-trips:
requestPasswordReset({ email })thenconfirmPasswordReset({ token, password })with the emailed token, after whichlogin()with the NEW password succeeds and the OLD password no longer works. - Authorization holds: a signed-in user only ever reads/edits their OWN
record (
getUser()is the caller's profile, never another user's); the token's storage is the deliberate choiceAuthClientConfig.storagenames — if it is Web Storage (the scaffold default, which a reload requires when the API sets no cookie), say so in the app's own words rather than leaving a second, undocumented store beside it; and an expired/invalid token fails closed — the app re-authenticates (refresh()orlogout()), never silently serving another user's data.
Translations
Translation strings are provided by @molecule/app-locales-auth.