@zakkster/lite-auth 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Auth.d.ts ADDED
@@ -0,0 +1,191 @@
1
+ // Type definitions for @zakkster/lite-auth
2
+ // Project: https://www.npmjs.com/package/@zakkster/lite-auth
3
+ // Definitions by: Zahary Shinikchiev
4
+
5
+ /**
6
+ * A read-only reactive value. Call it to read (tracked inside a computation),
7
+ * `.peek()` to read without tracking, `.subscribe()` to observe changes. This
8
+ * is the lite-signal ReadonlySignal shape, restated here so the type surface is
9
+ * self-contained.
10
+ */
11
+ export interface ReadonlySignal<T> {
12
+ (): T;
13
+ peek(): T;
14
+ subscribe(run: (value: T) => void): () => void;
15
+ }
16
+
17
+ export type AuthErrorCode =
18
+ | "invalid_credentials"
19
+ | "network"
20
+ | "refresh_failed"
21
+ | "no_refresh_token"
22
+ | "expired"
23
+ | "storage"
24
+ | "aborted"
25
+ | "misconfigured";
26
+
27
+ /**
28
+ * Typed error thrown by the awaited actions (`signIn`, `refresh`) and surfaced
29
+ * reactively on `error` for ambient failures.
30
+ */
31
+ export class AuthError extends Error {
32
+ name: "AuthError";
33
+ code: AuthErrorCode;
34
+ cause?: unknown;
35
+ constructor(code: AuthErrorCode, message: string, opts?: { cause?: unknown });
36
+ }
37
+
38
+ export type AuthStatus = "idle" | "authenticating" | "refreshing";
39
+
40
+ /**
41
+ * The unit of authenticated state. `user` is opaque to lite-auth and surfaced
42
+ * as-is through the `session` projection.
43
+ */
44
+ export interface SessionRecord<User = unknown> {
45
+ user: User;
46
+ accessToken: string;
47
+ refreshToken?: string;
48
+ /** Epoch milliseconds. When present, drives expiry checks and refresh scheduling. */
49
+ expiresAt?: number;
50
+ }
51
+
52
+ /**
53
+ * Pluggable authentication backend. Only `signIn` is required; provide
54
+ * `refresh` to enable token renewal and `signOut` for server-side revocation.
55
+ */
56
+ export interface AuthAdapter<User = unknown, Credentials = unknown> {
57
+ signIn(credentials: Credentials, signal: AbortSignal): Promise<SessionRecord<User>>;
58
+ refresh?(record: SessionRecord<User>, signal: AbortSignal): Promise<SessionRecord<User>>;
59
+ signOut?(record: SessionRecord<User>): Promise<void> | void;
60
+ }
61
+
62
+ export interface RefreshConfig {
63
+ enabled: boolean;
64
+ /** Seconds before `expiresAt` at which to refresh. Default 60. */
65
+ threshold?: number;
66
+ }
67
+
68
+ /** Anything Web-Storage-shaped. */
69
+ export interface StorageLike {
70
+ getItem(key: string): string | null;
71
+ setItem(key: string, value: string): void;
72
+ removeItem(key: string): void;
73
+ }
74
+
75
+ export type StorageOption = "localStorage" | "sessionStorage" | "memory" | StorageLike;
76
+
77
+ /**
78
+ * Minimal registry shape (a subset of the object returned by lite-signal's
79
+ * `createRegistry`). lite-auth only ever uses `signal`, `computed`, and
80
+ * `batch`, so any compatible registry works. The optional `dispose` is the
81
+ * registry-bound node disposer; when present, lite-auth uses it on teardown
82
+ * to return signal and computed nodes to the right pool.
83
+ */
84
+ export interface SignalRegistry {
85
+ signal: <T>(initial: T) => ReadonlySignal<T> & {
86
+ set(value: T): void;
87
+ update(fn: (value: T) => T): void;
88
+ };
89
+ computed: <T>(fn: () => T) => ReadonlySignal<T>;
90
+ batch: <T>(fn: () => T) => T;
91
+ dispose?: (node: unknown) => void;
92
+ }
93
+
94
+ export interface AuthConfig<User = unknown, Credentials = unknown> {
95
+ /** The backend that exchanges credentials for a session record. */
96
+ adapter: AuthAdapter<User, Credentials>;
97
+ /**
98
+ * Where to persist the session. Default `"localStorage"`; throws an
99
+ * AuthError("misconfigured") if Web Storage is unavailable. Use `"memory"`
100
+ * for SSR, tests, or session-only flows.
101
+ */
102
+ storage?: StorageOption;
103
+ /** Storage key and default cross-tab channel key. Default `"lite-auth.session.v1"`. */
104
+ storageKey?: string;
105
+ /** BroadcastChannel name for cross-tab sync. Defaults to `storageKey`. */
106
+ channelName?: string;
107
+ /** Enable cross-tab propagation. Requires `@zakkster/lite-channel` to be installed. Default `false`. */
108
+ crossTab?: boolean;
109
+ /** Opt-in background token refresh. */
110
+ refresh?: RefreshConfig;
111
+ /** Forwarded to lite-channel's `createTabSync`. `persist` is always forced off (lite-auth owns disk). */
112
+ channelOptions?: Record<string, unknown>;
113
+ /**
114
+ * Advanced: run lite-auth's own signals inside a custom lite-signal
115
+ * registry for isolation. Note that lite-persist's write-back uses the
116
+ * global signal graph, so a custom registry pairs best with
117
+ * `storage: "memory"`.
118
+ */
119
+ registry?: SignalRegistry;
120
+ /** Fired when an unauthenticated session becomes authenticated (not on restore). */
121
+ onSignIn?: (user: User) => void;
122
+ /** Fired when an authenticated session becomes unauthenticated (local or cross-tab). */
123
+ onSignOut?: () => void;
124
+ /** Fired when a session expires and cannot be refreshed. */
125
+ onSessionExpire?: () => void;
126
+ /** Fired after a successful token refresh. */
127
+ onTokenRefresh?: (record: SessionRecord<User>) => void;
128
+ /** Ambient (non-thrown) failures: background refresh, storage, cross-tab. */
129
+ onError?: (error: AuthError) => void;
130
+ }
131
+
132
+ export interface Auth<User = unknown, Credentials = unknown> {
133
+ /** The current user, or `null` when unauthenticated. */
134
+ readonly session: ReadonlySignal<User | null>;
135
+ /** Whether a session is currently active. */
136
+ readonly isAuthenticated: ReadonlySignal<boolean>;
137
+ /** The current access token, or `null`. */
138
+ readonly token: ReadonlySignal<string | null>;
139
+ /** The current session's expiry in epoch ms, or `null`. */
140
+ readonly expiresAt: ReadonlySignal<number | null>;
141
+ /** Coarse activity state for spinners and guards. */
142
+ readonly status: ReadonlySignal<AuthStatus>;
143
+ /** The most recent error, cleared on the next successful action. */
144
+ readonly error: ReadonlySignal<AuthError | null>;
145
+ /** Resolves once boot hydration, optional channel attach, and any boot-time refresh have settled. */
146
+ readonly ready: Promise<void>;
147
+ /** Exchange credentials for a session. Rejects with an AuthError on failure. */
148
+ signIn(credentials: Credentials): Promise<User>;
149
+ /** Clear the session locally (optimistic) and best-effort revoke server-side. Never rejects. */
150
+ signOut(): Promise<void>;
151
+ /** Force a token refresh now. Rejects with an AuthError on failure. */
152
+ refresh(): Promise<void>;
153
+ /** Register a sign-in listener. Returns an idempotent disposer. */
154
+ onSignIn(fn: (user: User) => void): () => void;
155
+ /** Register a sign-out listener. Returns an idempotent disposer. */
156
+ onSignOut(fn: () => void): () => void;
157
+ /** Register a session-expiry listener. Returns an idempotent disposer. */
158
+ onSessionExpire(fn: () => void): () => void;
159
+ /** Register a token-refresh listener. Returns an idempotent disposer. */
160
+ onTokenRefresh(fn: (record: SessionRecord<User>) => void): () => void;
161
+ /** Tear down timers, subscriptions, and the channel. Leaves stored data intact. */
162
+ dispose(): void;
163
+ }
164
+
165
+ /** Create a reactive authentication controller. */
166
+ export function createAuth<User = unknown, Credentials = unknown>(
167
+ config: AuthConfig<User, Credentials>,
168
+ ): Auth<User, Credentials>;
169
+
170
+ export interface FetchAdapterOptions<User = unknown> {
171
+ signInUrl: string;
172
+ refreshUrl?: string;
173
+ signOutUrl?: string;
174
+ /** Static headers, or a getter evaluated per request so rotated tokens stay fresh. */
175
+ headers?: Record<string, string> | (() => Record<string, string>);
176
+ /** Map a raw JSON response to a SessionRecord. Defaults to reading `{ user, accessToken, refreshToken?, expiresAt? }`. */
177
+ parseSession?: (json: unknown) => SessionRecord<User>;
178
+ /** Override the fetch implementation (defaults to `globalThis.fetch`). */
179
+ fetch?: typeof fetch;
180
+ }
181
+
182
+ /**
183
+ * A REST adapter over fetch. POSTs JSON to the configured endpoints. When a
184
+ * response omits `expiresAt`, it is derived from the access token's JWT `exp`.
185
+ */
186
+ export function fetchAdapter<User = unknown, Credentials = unknown>(
187
+ opts: FetchAdapterOptions<User>,
188
+ ): AuthAdapter<User, Credentials>;
189
+
190
+ /** Decode a JWT's `exp` claim and return it as epoch milliseconds, or `undefined`. */
191
+ export function decodeJwtExp(token: string): number | undefined;
package/Auth.js ADDED
@@ -0,0 +1,720 @@
1
+ /**
2
+ * @zakkster/lite-auth
3
+ *
4
+ * Session-as-a-signal authentication for the lite-* ecosystem.
5
+ *
6
+ * The whole of auth state lives in one reactive value. Everything an
7
+ * application binds to is a projection of that value:
8
+ *
9
+ * signal<SessionRecord | undefined> (private source of truth)
10
+ * |- session computed -> User | null
11
+ * |- isAuthenticated computed -> boolean
12
+ * |- token computed -> string | null
13
+ * |- expiresAt computed -> number | null
14
+ *
15
+ * Persistence is delegated to @zakkster/lite-persist (boot hydration plus
16
+ * debounced write-through). Cross-tab propagation is delegated to
17
+ * @zakkster/lite-channel (BroadcastChannel, presence, leader election).
18
+ * The two are kept on separate axes: lite-persist owns disk with its own
19
+ * cross-tab path disabled (syncTabs:false); lite-channel owns the wire.
20
+ *
21
+ * Token refresh is timer-based and, under crossTab, performed only by the
22
+ * leader tab. Refresh tokens are frequently single-use, so electing one
23
+ * refresher avoids a rotation race across tabs; followers receive the new
24
+ * record over the channel.
25
+ *
26
+ * MIT License. Copyright (c) Zahary Shinikchiev.
27
+ */
28
+
29
+ import {
30
+ signal as defaultSignal,
31
+ computed as defaultComputed,
32
+ batch as defaultBatch,
33
+ dispose as disposeReactive,
34
+ } from "@zakkster/lite-signal";
35
+ import {persist} from "@zakkster/lite-persist";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Errors
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Typed error thrown by awaited imperative actions (signIn / refresh) and
43
+ * surfaced reactively on the `error` signal for ambient failures.
44
+ *
45
+ * The `code` discriminant mirrors the named-error pattern used elsewhere in
46
+ * the ecosystem (see lite-signal's CapacityError).
47
+ */
48
+ export class AuthError extends Error {
49
+ /**
50
+ * @param {("invalid_credentials"|"network"|"refresh_failed"|"no_refresh_token"|"expired"|"storage"|"aborted"|"misconfigured")} code
51
+ * @param {string} message
52
+ * @param {{ cause?: unknown }} [opts]
53
+ */
54
+ constructor(code, message, opts) {
55
+ super(message);
56
+ this.name = "AuthError";
57
+ this.code = code;
58
+ if (opts && "cause" in opts) this.cause = opts.cause;
59
+ }
60
+ }
61
+
62
+ function toAuthError(err, fallbackCode) {
63
+ if (err instanceof AuthError) return err;
64
+ // DOMException / AbortError from fetch and AbortController.
65
+ if (err && (err.name === "AbortError" || err.code === 20)) {
66
+ return new AuthError("aborted", "operation aborted", {cause: err});
67
+ }
68
+ // Network-layer failures from fetch reject as TypeError.
69
+ if (err instanceof TypeError) {
70
+ return new AuthError("network", err.message || "network error", {cause: err});
71
+ }
72
+ return new AuthError(fallbackCode, err && err.message ? err.message : String(err), {cause: err});
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Record validation and helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Validate the structural shape of a session record. Throws a plain Error
81
+ * (mapped to the caller's fallback code by toAuthError) on malformed input.
82
+ */
83
+ function validateRecord(rec) {
84
+ if (rec == null || typeof rec !== "object") {
85
+ throw new Error("lite-auth: session record must be an object");
86
+ }
87
+ if (rec.user == null) {
88
+ throw new Error("lite-auth: session record is missing `user`");
89
+ }
90
+ if (typeof rec.accessToken !== "string" || rec.accessToken.length === 0) {
91
+ throw new Error("lite-auth: session record is missing a string `accessToken`");
92
+ }
93
+ if (rec.refreshToken != null && typeof rec.refreshToken !== "string") {
94
+ throw new Error("lite-auth: `refreshToken` must be a string when present");
95
+ }
96
+ if (rec.expiresAt != null && typeof rec.expiresAt !== "number") {
97
+ throw new Error("lite-auth: `expiresAt` must be a number (epoch ms) when present");
98
+ }
99
+ return rec;
100
+ }
101
+
102
+ function isExpired(rec, nowMs) {
103
+ return rec != null && rec.expiresAt != null && nowMs >= rec.expiresAt;
104
+ }
105
+
106
+ /**
107
+ * Decode the `exp` claim of a JWT and return it as epoch milliseconds.
108
+ * Returns undefined for non-JWTs or unreadable payloads. Pure and zero-dep;
109
+ * works under both browser (atob) and Node (Buffer).
110
+ *
111
+ * @param {string} token
112
+ * @returns {number | undefined}
113
+ */
114
+ export function decodeJwtExp(token) {
115
+ if (typeof token !== "string") return undefined;
116
+ const dot1 = token.indexOf(".");
117
+ if (dot1 < 0) return undefined;
118
+ const dot2 = token.indexOf(".", dot1 + 1);
119
+ if (dot2 < 0) return undefined;
120
+ let b64 = token.slice(dot1 + 1, dot2).replace(/-/g, "+").replace(/_/g, "/");
121
+ const padLen = (4 - (b64.length % 4)) % 4;
122
+ if (padLen > 0) b64 += padLen === 1 ? "=" : padLen === 2 ? "==" : "===";
123
+ let json;
124
+ try {
125
+ if (typeof atob === "function") {
126
+ json = atob(b64);
127
+ } else if (typeof Buffer !== "undefined") {
128
+ json = Buffer.from(b64, "base64").toString("binary");
129
+ } else {
130
+ return undefined;
131
+ }
132
+ const payload = JSON.parse(json);
133
+ if (payload && typeof payload.exp === "number") return payload.exp * 1000;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ /**
141
+ * Resolve the `storage` option into a Web-Storage-shaped backend, or null for
142
+ * memory mode (signal-only, no disk).
143
+ */
144
+ function resolveStorage(storage) {
145
+ if (storage === "memory") return null;
146
+ if (storage == null || storage === "localStorage") {
147
+ if (typeof globalThis.localStorage === "undefined") {
148
+ throw new AuthError(
149
+ "misconfigured",
150
+ 'localStorage is unavailable; use storage:"memory" outside the browser',
151
+ );
152
+ }
153
+ return globalThis.localStorage;
154
+ }
155
+ if (storage === "sessionStorage") {
156
+ if (typeof globalThis.sessionStorage === "undefined") {
157
+ throw new AuthError(
158
+ "misconfigured",
159
+ 'sessionStorage is unavailable; use storage:"memory" outside the browser',
160
+ );
161
+ }
162
+ return globalThis.sessionStorage;
163
+ }
164
+ if (typeof storage.getItem === "function" && typeof storage.setItem === "function") {
165
+ return storage; // custom StorageLike
166
+ }
167
+ throw new AuthError("misconfigured", "unknown storage backend: " + String(storage));
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Built-in fetch adapter
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * A REST adapter over fetch. POSTs JSON to the configured endpoints and parses
176
+ * `{ user, accessToken, refreshToken?, expiresAt? }` from each response. When
177
+ * `expiresAt` is absent it is derived from the access token's JWT `exp` claim.
178
+ *
179
+ * @param {{
180
+ * signInUrl: string,
181
+ * refreshUrl?: string,
182
+ * signOutUrl?: string,
183
+ * headers?: Record<string,string> | (() => Record<string,string>),
184
+ * parseSession?: (json: unknown) => any,
185
+ * fetch?: typeof fetch,
186
+ * }} opts
187
+ */
188
+ export function fetchAdapter(opts) {
189
+ if (!opts || typeof opts.signInUrl !== "string") {
190
+ throw new AuthError("misconfigured", "fetchAdapter requires a signInUrl");
191
+ }
192
+ const f = opts.fetch || globalThis.fetch;
193
+ if (typeof f !== "function") {
194
+ throw new AuthError("misconfigured", "no fetch implementation available");
195
+ }
196
+ // Headers may be a static object or a getter evaluated per request. A getter
197
+ // keeps dynamic values (e.g. an Authorization token rotated by background
198
+ // refresh) fresh on every call rather than frozen at adapter-construction.
199
+ const headerOpt = opts.headers;
200
+ const resolveHeaders = () => {
201
+ const dyn = typeof headerOpt === "function" ? headerOpt() : headerOpt;
202
+ return Object.assign({"content-type": "application/json"}, dyn || {});
203
+ };
204
+
205
+ function parse(json) {
206
+ if (opts.parseSession) return validateRecord(opts.parseSession(json));
207
+ const rec = {
208
+ user: json && json.user,
209
+ accessToken: json && json.accessToken,
210
+ refreshToken: json && json.refreshToken,
211
+ expiresAt: json && json.expiresAt,
212
+ };
213
+ if (rec.expiresAt == null && typeof rec.accessToken === "string") {
214
+ rec.expiresAt = decodeJwtExp(rec.accessToken);
215
+ }
216
+ return validateRecord(rec);
217
+ }
218
+
219
+ const adapter = {
220
+ async signIn(credentials, signal) {
221
+ const res = await f(opts.signInUrl, {
222
+ method: "POST",
223
+ headers: resolveHeaders(),
224
+ body: JSON.stringify(credentials),
225
+ signal,
226
+ });
227
+ if (!res.ok) {
228
+ throw new AuthError("invalid_credentials", "sign-in failed with status " + res.status);
229
+ }
230
+ return parse(await res.json());
231
+ },
232
+ };
233
+
234
+ if (opts.refreshUrl) {
235
+ adapter.refresh = async (record, signal) => {
236
+ // The built-in REST adapter clearly cannot refresh without a token.
237
+ // Surface the documented `no_refresh_token` code rather than letting
238
+ // the server 4xx through as a generic `refresh_failed`. Custom
239
+ // adapters remain free to handle a missing token however they like;
240
+ // the core does not preempt them.
241
+ if (record.refreshToken == null) {
242
+ throw new AuthError("no_refresh_token", "no refresh token in current session");
243
+ }
244
+ const res = await f(opts.refreshUrl, {
245
+ method: "POST",
246
+ headers: resolveHeaders(),
247
+ body: JSON.stringify({refreshToken: record.refreshToken}),
248
+ signal,
249
+ });
250
+ if (!res.ok) {
251
+ throw new AuthError("refresh_failed", "token refresh failed with status " + res.status);
252
+ }
253
+ return parse(await res.json());
254
+ };
255
+ }
256
+
257
+ if (opts.signOutUrl) {
258
+ adapter.signOut = async (record) => {
259
+ await f(opts.signOutUrl, {
260
+ method: "POST",
261
+ headers: resolveHeaders(),
262
+ body: JSON.stringify({refreshToken: record.refreshToken}),
263
+ });
264
+ };
265
+ }
266
+
267
+ return adapter;
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // createAuth
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Create a reactive authentication controller.
276
+ *
277
+ * @param {import("./Auth.js").AuthConfig} config
278
+ * @returns {import("./Auth.js").Auth}
279
+ */
280
+ export function createAuth(config) {
281
+ if (!config || !config.adapter || typeof config.adapter.signIn !== "function") {
282
+ throw new AuthError("misconfigured", "createAuth requires an adapter with a signIn() method");
283
+ }
284
+
285
+ const adapter = config.adapter;
286
+ const R = config.registry || {signal: defaultSignal, computed: defaultComputed, batch: defaultBatch};
287
+ const storageKey = config.storageKey || "lite-auth.session.v1";
288
+ const channelName = config.channelName || storageKey;
289
+ const crossTab = config.crossTab === true;
290
+ const refreshCfg = config.refresh || {enabled: false};
291
+ const refreshEnabled = refreshCfg.enabled === true;
292
+ const threshold = (typeof refreshCfg.threshold === "number" ? refreshCfg.threshold : 60) * 1000;
293
+ const onError = typeof config.onError === "function" ? config.onError : null;
294
+
295
+ // -- core state ---------------------------------------------------------
296
+ // The empty state is `undefined`, not `null`: lite-persist removes a key
297
+ // only when the signal value is `undefined` (a `null` would be written as
298
+ // the string "null"). Public projections still surface `null` for "no
299
+ // user"; all internal emptiness checks are loose (`== null`).
300
+ const _record = R.signal(/** @type {any} */ (undefined));
301
+ const status = R.signal("idle");
302
+ const error = R.signal(/** @type {AuthError | null} */ (null));
303
+
304
+ const session = R.computed(() => {
305
+ const r = _record();
306
+ return r ? r.user : null;
307
+ });
308
+ const isAuthenticated = R.computed(() => _record() != null);
309
+ const token = R.computed(() => {
310
+ const r = _record();
311
+ return r ? r.accessToken : null;
312
+ });
313
+ const expiresAt = R.computed(() => {
314
+ const r = _record();
315
+ return r && r.expiresAt != null ? r.expiresAt : null;
316
+ });
317
+
318
+ // -- generation counter: bumps on every record mutation (local, refresh,
319
+ // sign-out, or cross-tab). Async actions capture it before awaiting and
320
+ // bail if it moved underneath them.
321
+ let gen = 0;
322
+ const disposers = [];
323
+ disposers.push(_record.subscribe(() => {
324
+ gen++;
325
+ }));
326
+
327
+ // Mutable wiring shared across boot, scheduling, and teardown. Declared up
328
+ // here so the boot-time refresh path below can touch them without tripping
329
+ // the temporal dead zone.
330
+ let timer = null;
331
+ let bus = null;
332
+ let busReady = false;
333
+ let refreshAbort = null;
334
+ let signInAbort = null;
335
+ let disposed = false;
336
+ let wireNode = null; // cross-tab string-wire signal, disposed on teardown
337
+
338
+ function setError(err) {
339
+ error.set(err);
340
+ if (onError) {
341
+ try {
342
+ onError(err);
343
+ } catch (e) {
344
+ reportHookError(e);
345
+ }
346
+ }
347
+ }
348
+
349
+ function clearError() {
350
+ if (error.peek() !== null) error.set(null);
351
+ }
352
+
353
+ // -- lifecycle hooks ----------------------------------------------------
354
+ const signInCbs = [];
355
+ const signOutCbs = [];
356
+ const expireCbs = [];
357
+ const refreshCbs = [];
358
+
359
+ function register(list, fn) {
360
+ list.push(fn);
361
+ let live = true;
362
+ return () => {
363
+ if (!live) return;
364
+ live = false;
365
+ const i = list.indexOf(fn);
366
+ if (i >= 0) list.splice(i, 1);
367
+ };
368
+ }
369
+
370
+ function reportHookError(e) {
371
+ // A throwing user callback must not break the others or the graph.
372
+ if (typeof console !== "undefined" && console.error) {
373
+ console.error("lite-auth: lifecycle hook threw", e);
374
+ }
375
+ }
376
+
377
+ function emit(list, arg) {
378
+ // Iterate a copy so a callback may dispose itself mid-emit.
379
+ const copy = list.slice();
380
+ for (let i = 0; i < copy.length; i++) {
381
+ try {
382
+ copy[i](arg);
383
+ } catch (e) {
384
+ reportHookError(e);
385
+ }
386
+ }
387
+ }
388
+
389
+ if (config.onSignIn) register(signInCbs, config.onSignIn);
390
+ if (config.onSignOut) register(signOutCbs, config.onSignOut);
391
+ if (config.onSessionExpire) register(expireCbs, config.onSessionExpire);
392
+ if (config.onTokenRefresh) register(refreshCbs, config.onTokenRefresh);
393
+
394
+ // -- persistence (lite-persist; cross-tab path disabled) ----------------
395
+ const backend = resolveStorage(config.storage);
396
+ if (backend) {
397
+ // Pre-validate any stored value so a corrupt entry cannot throw out of
398
+ // createAuth via lite-persist's synchronous boot read.
399
+ try {
400
+ const raw = backend.getItem(storageKey);
401
+ if (raw !== null) {
402
+ const parsed = JSON.parse(raw);
403
+ if (parsed != null) validateRecord(parsed);
404
+ }
405
+ } catch (e) {
406
+ try {
407
+ backend.removeItem(storageKey);
408
+ } catch { /* ignore */
409
+ }
410
+ setError(new AuthError("storage", "discarded a corrupt stored session", {cause: e}));
411
+ }
412
+ try {
413
+ const stop = persist(_record, storageKey, {
414
+ storage: backend,
415
+ syncTabs: false, // lite-channel owns cross-tab
416
+ debounce: 0, // session writes are rare; favour durability
417
+ flushOnDispose: true,
418
+ deserialize: (str) => {
419
+ const v = JSON.parse(str);
420
+ return v == null ? undefined : validateRecord(v);
421
+ },
422
+ });
423
+ disposers.push(stop);
424
+ } catch (e) {
425
+ setError(new AuthError("storage", "failed to initialise persistence", {cause: e}));
426
+ }
427
+ }
428
+
429
+ // -- boot expiry handling ----------------------------------------------
430
+ let bootSettled = Promise.resolve();
431
+ {
432
+ const rec0 = _record.peek();
433
+ if (isExpired(rec0, Date.now())) {
434
+ if (refreshEnabled && adapter.refresh && rec0.refreshToken) {
435
+ bootSettled = doRefresh(rec0, false).catch(() => {
436
+ });
437
+ } else {
438
+ emit(expireCbs);
439
+ _record.set(undefined); // lite-persist removes the key
440
+ }
441
+ }
442
+ }
443
+
444
+ // -- lifecycle dispatch (attached post-hydration so a restored session is
445
+ // the baseline and does not fire onSignIn) ---------------------------
446
+ let lifeInit = false;
447
+ let prevHadUser = false;
448
+ disposers.push(_record.subscribe((rec) => {
449
+ const hasUser = rec != null;
450
+ if (!lifeInit) {
451
+ lifeInit = true;
452
+ prevHadUser = hasUser;
453
+ return;
454
+ }
455
+ if (hasUser === prevHadUser) {
456
+ prevHadUser = hasUser;
457
+ return;
458
+ }
459
+ prevHadUser = hasUser;
460
+ if (hasUser) emit(signInCbs, rec.user);
461
+ else emit(signOutCbs);
462
+ }));
463
+
464
+ // -- refresh scheduling -------------------------------------------------
465
+ function leaderNow() {
466
+ if (!crossTab) return true;
467
+ if (!busReady || !bus) return false; // do not schedule before the bus is wired
468
+ return bus.isLeader.peek();
469
+ }
470
+
471
+ function rearm() {
472
+ if (timer !== null) {
473
+ clearTimeout(timer);
474
+ timer = null;
475
+ }
476
+ if (disposed || !refreshEnabled || !adapter.refresh) return;
477
+ const rec = _record.peek();
478
+ if (rec == null || rec.expiresAt == null) return;
479
+ if (!leaderNow()) return;
480
+ const delay = Math.max(0, rec.expiresAt - Date.now() - threshold);
481
+ timer = setTimeout(() => {
482
+ timer = null;
483
+ const r = _record.peek();
484
+ if (r != null) doRefresh(r, false).catch(() => {
485
+ });
486
+ }, delay);
487
+ }
488
+
489
+ // (Re)arm whenever the record changes. subscribe fires once on attach,
490
+ // establishing the baseline schedule for a hydrated session.
491
+ disposers.push(_record.subscribe(rearm));
492
+
493
+ async function doRefresh(rec, manual) {
494
+ if (refreshAbort) refreshAbort.abort();
495
+ refreshAbort = new AbortController();
496
+ const mySignal = refreshAbort.signal;
497
+ const myGen = gen;
498
+ status.set("refreshing");
499
+ let next;
500
+ try {
501
+ if (!adapter.refresh) throw new AuthError("misconfigured", "adapter has no refresh()");
502
+ next = await adapter.refresh(rec, mySignal);
503
+ next = validateRecord(next);
504
+ } catch (e) {
505
+ if (status.peek() === "refreshing") status.set("idle");
506
+ const err = toAuthError(e, "refresh_failed");
507
+ // Superseded mid-flight (sign-out, fresh sign-in, or cross-tab change).
508
+ if (mySignal.aborted || gen !== myGen) {
509
+ if (manual) throw err;
510
+ return;
511
+ }
512
+ setError(err);
513
+ // Unrecoverable: the session is effectively dead. Expire then clear.
514
+ emit(expireCbs);
515
+ _record.set(undefined);
516
+ if (manual) throw err;
517
+ return;
518
+ }
519
+ if (mySignal.aborted || gen !== myGen) {
520
+ if (status.peek() === "refreshing") status.set("idle");
521
+ if (manual) throw new AuthError("aborted", "refresh superseded");
522
+ return;
523
+ }
524
+ R.batch(() => {
525
+ clearError();
526
+ _record.set(next);
527
+ status.set("idle");
528
+ });
529
+ emit(refreshCbs, next);
530
+ }
531
+
532
+ // -- cross-tab wiring (async; lite-channel is an optional peer) ----------
533
+ if (crossTab) {
534
+ const attach = (async () => {
535
+ let mod;
536
+ try {
537
+ mod = await import("@zakkster/lite-channel");
538
+ } catch (e) {
539
+ setError(new AuthError(
540
+ "misconfigured",
541
+ 'crossTab:true requires @zakkster/lite-channel to be installed',
542
+ {cause: e},
543
+ ));
544
+ return;
545
+ }
546
+ if (disposed) return;
547
+ const opts = Object.assign({}, config.channelOptions, {persist: false});
548
+ bus = mod.createTabSync(channelName, opts);
549
+
550
+ // lite-channel applies inbound values inside a lite-signal batch().
551
+ // Because batch defers subscriber notifications to commit time, its
552
+ // internal "applying" echo guard has already been cleared when the
553
+ // subscriber finally runs -- so syncing the record OBJECT directly
554
+ // ping-pongs forever (every structuredClone hop is a fresh reference
555
+ // that never dedupes by Object.is). We therefore sync a STRING wire:
556
+ // identical strings dedupe by value, so when the originating tab
557
+ // receives the echo its wire is unchanged and the loop stops.
558
+ const EMPTY = "\u0000"; // "no session" sentinel; never collides with JSON
559
+ const enc = (rec) => (rec == null ? EMPTY : JSON.stringify(rec));
560
+ const _wire = R.signal(enc(_record.peek()));
561
+ wireNode = _wire;
562
+ let lastWire = enc(_record.peek());
563
+
564
+ // Outbound: a local record change is pushed onto the wire to broadcast.
565
+ disposers.push(_record.subscribe(() => {
566
+ const s = enc(_record.peek());
567
+ if (s === lastWire) return; // wire already reflects this value
568
+ lastWire = s;
569
+ _wire.set(s);
570
+ }));
571
+
572
+ // Inbound: a genuinely new wire value (from another tab) is adopted
573
+ // into the record. Setting lastWire first makes the outbound
574
+ // subscriber treat the resulting record change as already-sent.
575
+ disposers.push(_wire.subscribe(() => {
576
+ const s = _wire.peek();
577
+ if (s === lastWire) return; // our own reflection or an echo we hold
578
+ lastWire = s;
579
+ let next;
580
+ if (s === EMPTY) {
581
+ next = undefined;
582
+ } else {
583
+ try {
584
+ next = validateRecord(JSON.parse(s));
585
+ } catch {
586
+ return; // ignore an unparseable/foreign payload
587
+ }
588
+ }
589
+ _record.set(next);
590
+ }));
591
+
592
+ bus.sync(_wire);
593
+ busReady = true;
594
+ disposers.push(bus.isLeader.subscribe(rearm));
595
+ rearm(); // leadership is known now; schedule if we are the leader
596
+ })();
597
+ bootSettled = bootSettled.then(() => attach);
598
+ }
599
+
600
+ const ready = bootSettled.then(() => undefined);
601
+
602
+ // -- public actions -----------------------------------------------------
603
+ async function signIn(credentials) {
604
+ if (signInAbort) signInAbort.abort();
605
+ signInAbort = new AbortController();
606
+ const mySignal = signInAbort.signal;
607
+ const myGen = gen;
608
+ status.set("authenticating");
609
+ let rec;
610
+ try {
611
+ rec = await adapter.signIn(credentials, mySignal);
612
+ rec = validateRecord(rec);
613
+ } catch (e) {
614
+ if (status.peek() === "authenticating") status.set("idle");
615
+ const err = toAuthError(e, "invalid_credentials");
616
+ setError(err);
617
+ throw err;
618
+ }
619
+ if (mySignal.aborted || gen !== myGen) {
620
+ if (status.peek() === "authenticating") status.set("idle");
621
+ throw new AuthError("aborted", "sign-in superseded");
622
+ }
623
+ R.batch(() => {
624
+ clearError();
625
+ _record.set(rec);
626
+ status.set("idle");
627
+ });
628
+ return rec.user;
629
+ }
630
+
631
+ async function signOut() {
632
+ const rec = _record.peek();
633
+ if (refreshAbort) refreshAbort.abort();
634
+ if (signInAbort) signInAbort.abort();
635
+ // Optimistic local clear: logout always succeeds locally and offline.
636
+ R.batch(() => {
637
+ _record.set(undefined);
638
+ status.set("idle");
639
+ });
640
+ if (rec && adapter.signOut) {
641
+ try {
642
+ await adapter.signOut(rec);
643
+ } catch (e) {
644
+ // Best effort: a failed server revoke never resurrects the
645
+ // local session and never throws from signOut.
646
+ setError(toAuthError(e, "network"));
647
+ }
648
+ }
649
+ }
650
+
651
+ async function refresh() {
652
+ const rec = _record.peek();
653
+ if (rec == null) throw new AuthError("expired", "no active session to refresh");
654
+ if (!adapter.refresh) throw new AuthError("misconfigured", "adapter has no refresh()");
655
+ if (rec.refreshToken == null) {
656
+ // Adapters may not need a refresh token; only flag when the default
657
+ // contract clearly cannot proceed. Left permissive: the adapter is
658
+ // the authority. Callers relying on tokens should ensure one exists.
659
+ }
660
+ return doRefresh(rec, true);
661
+ }
662
+
663
+ function dispose() {
664
+ if (disposed) return;
665
+ disposed = true;
666
+ if (timer !== null) {
667
+ clearTimeout(timer);
668
+ timer = null;
669
+ }
670
+ if (refreshAbort) refreshAbort.abort();
671
+ if (signInAbort) signInAbort.abort();
672
+ for (let i = 0; i < disposers.length; i++) {
673
+ try {
674
+ disposers[i]();
675
+ } catch { /* idempotent */
676
+ }
677
+ }
678
+ disposers.length = 0;
679
+ if (bus) {
680
+ try {
681
+ bus.dispose();
682
+ } catch { /* ignore */
683
+ }
684
+ bus = null;
685
+ }
686
+ // Release pooled reactive nodes back to the registry, so repeated
687
+ // create/dispose cycles (SSR per request, test harnesses) do not
688
+ // accumulate nodes. For a custom registry, use its own dispose so the
689
+ // right pool is touched -- the imported disposeReactive is bound to
690
+ // the default registry and would silently no-op on foreign nodes. A
691
+ // no-op for registries whose primitives are not lite-signal nodes.
692
+ const disposeNode = typeof R.dispose === "function" ? R.dispose : disposeReactive;
693
+ const nodes = [session, isAuthenticated, token, expiresAt, _record, status, error];
694
+ if (wireNode) nodes.push(wireNode);
695
+ for (let i = 0; i < nodes.length; i++) {
696
+ try {
697
+ disposeNode(nodes[i]);
698
+ } catch { /* ignore */
699
+ }
700
+ }
701
+ }
702
+
703
+ return {
704
+ session,
705
+ isAuthenticated,
706
+ token,
707
+ expiresAt,
708
+ status,
709
+ error,
710
+ ready,
711
+ signIn,
712
+ signOut,
713
+ refresh,
714
+ onSignIn: (fn) => register(signInCbs, fn),
715
+ onSignOut: (fn) => register(signOutCbs, fn),
716
+ onSessionExpire: (fn) => register(expireCbs, fn),
717
+ onTokenRefresh: (fn) => register(refreshCbs, fn),
718
+ dispose,
719
+ };
720
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-06-02
9
+
10
+ ### Added
11
+
12
+ - `createAuth(config)`: a reactive authentication controller built around one
13
+ private `signal<SessionRecord | undefined>` source of truth.
14
+ - Reactive projections: `session`, `isAuthenticated`, `token`, `expiresAt`,
15
+ `status`, and `error`.
16
+ - Cross-tab logout and login over `BroadcastChannel` via the optional, lazily
17
+ loaded `@zakkster/lite-channel` peer (`crossTab: true`).
18
+ - Durable persistence via `@zakkster/lite-persist`: synchronous boot hydration
19
+ and debounced write-through, with `"localStorage"`, `"sessionStorage"`,
20
+ `"memory"`, and custom Web-Storage backends.
21
+ - Opt-in, timer-based token refresh that, under `crossTab`, is performed only by
22
+ the elected leader tab to avoid refresh-token rotation races.
23
+ - Lifecycle hooks `onSignIn`, `onSignOut`, `onSessionExpire`, `onTokenRefresh`,
24
+ each returning an idempotent disposer, plus matching config callbacks.
25
+ - Hybrid error model: awaited actions reject with a typed `AuthError`; ambient
26
+ failures surface on the `error` signal and the `onError` hook.
27
+ - `fetchAdapter(options)`: a REST adapter over fetch with JWT `exp` fallback for
28
+ `expiresAt`.
29
+ - `decodeJwtExp(token)`: a pure, zero-dependency JWT `exp` decoder.
30
+ - `ready` promise that resolves after boot hydration, optional channel attach,
31
+ and any boot-time refresh have settled.
32
+ - Full TypeScript definitions (`Auth.d.ts`).
33
+ - Test suite of 50 deterministic tests under `node --test`.
34
+
35
+ [1.0.0]: https://github.com/PeshoVurtoleta/lite-auth/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # @zakkster/lite-auth
2
+
3
+ Session-as-a-signal authentication for the `lite-*` ecosystem. The entire auth state is one reactive value; everything your UI binds to is a projection of it. Cross-tab logout, pluggable backends, optional leader-only token refresh, and durable persistence come built in. Zero runtime dependencies.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-auth.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-auth)
6
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-auth?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-auth)
8
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-auth?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-auth)
9
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-auth?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-auth)
10
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-auth/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://github.com/PeshoVurtoleta/lite-signal)
11
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
13
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
14
+
15
+ - One source of truth: a private `signal<SessionRecord | undefined>`.
16
+ - Reactive projections: `session`, `isAuthenticated`, `token`, `expiresAt`, `status`, `error`.
17
+ - Cross-tab logout (and login) over `BroadcastChannel`, via `@zakkster/lite-channel`.
18
+ - Durable boot hydration and debounced write-through, via `@zakkster/lite-persist`.
19
+ - Opt-in background refresh, performed by a single leader tab to avoid rotation races.
20
+ - Hybrid error model: awaited actions throw typed `AuthError`; ambient failures surface on a signal.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npm install @zakkster/lite-auth @zakkster/lite-signal @zakkster/lite-persist
26
+ # cross-tab is optional; install it only if you set crossTab: true
27
+ npm install @zakkster/lite-channel
28
+ ```
29
+
30
+ `@zakkster/lite-signal` and `@zakkster/lite-persist` are required peers. `@zakkster/lite-channel` is an optional peer, loaded lazily and only when `crossTab: true`.
31
+
32
+ ## Quick start
33
+
34
+ ```js
35
+ import { createAuth, fetchAdapter } from "@zakkster/lite-auth";
36
+
37
+ const auth = createAuth({
38
+ adapter: fetchAdapter({
39
+ signInUrl: "/api/login",
40
+ refreshUrl: "/api/refresh",
41
+ signOutUrl: "/api/logout",
42
+ }),
43
+ crossTab: true,
44
+ refresh: { enabled: true, threshold: 60 }, // refresh 60s before expiry
45
+ });
46
+
47
+ // Bind anywhere a lite-signal value can be read.
48
+ auth.isAuthenticated.subscribe((on) => {
49
+ document.body.dataset.auth = on ? "in" : "out";
50
+ });
51
+
52
+ await auth.ready; // boot hydration + channel attach settled
53
+
54
+ await auth.signIn({ username: "ada", password: "lovelace" });
55
+ console.log(auth.session.peek()); // { ...your user }
56
+ console.log(auth.token.peek()); // "eyJ..."
57
+
58
+ await auth.signOut(); // every other tab logs out too
59
+ ```
60
+
61
+ ## The model
62
+
63
+ The whole library is projections over one private record signal. Reading a projection inside a computation tracks it, so views update automatically.
64
+
65
+ ```mermaid
66
+ flowchart TD
67
+ REC["_record: signal&lt;SessionRecord | undefined&gt;"]
68
+ REC --> SESSION["session: User | null"]
69
+ REC --> ISAUTH["isAuthenticated: boolean"]
70
+ REC --> TOKEN["token: string | null"]
71
+ REC --> EXP["expiresAt: number | null"]
72
+ REC --> STATUS["status: idle | authenticating | refreshing"]
73
+ REC --> ERR["error: AuthError | null"]
74
+ ```
75
+
76
+ The empty state is `undefined` rather than `null`: `lite-persist` removes a stored key only when the signal value is `undefined`. The public projections still surface `null` for "no user".
77
+
78
+ ## Three decoupled axes
79
+
80
+ Persistence, cross-tab sync, and refresh are independent and individually optional. They are deliberately kept on separate channels so they never fight: `lite-persist` owns disk with its own cross-tab path disabled (`syncTabs: false`), and `lite-channel` owns the wire with persistence disabled (`persist: false`).
81
+
82
+ ```mermaid
83
+ flowchart LR
84
+ UI["your components"] -->|bind| PROJ["projections"]
85
+ PROJ --> REC["record signal"]
86
+ REC -->|"write-through (syncTabs:false)"| DISK["lite-persist to Storage"]
87
+ DISK -.->|"boot hydration"| REC
88
+ REC <-->|"string wire (persist:false)"| BUS["lite-channel / BroadcastChannel"]
89
+ REC -->|"leader-only timer"| RT["refresh()"]
90
+ RT -->|"new record"| REC
91
+ ```
92
+
93
+ ## Cross-tab logout
94
+
95
+ This is the headline feature. With `crossTab: true`, signing in or out in one tab propagates to every other tab on the same `channelName`. A logout in one tab clears the session everywhere; a login adopts the session in the others.
96
+
97
+ ```js
98
+ const auth = createAuth({ adapter, crossTab: true });
99
+ auth.onSignOut(() => router.push("/login")); // fires in the tab that logged out AND the others
100
+ ```
101
+
102
+ Only user-identity-plus-token state crosses the wire, never your credentials. Under the cookie/session pattern described below, nothing secret crosses at all.
103
+
104
+ ## Token refresh
105
+
106
+ Refresh is opt-in and timer-based. A refresh is scheduled `threshold` seconds before `expiresAt`; on success the new record replaces the old and the timer re-arms for the next cycle. On failure the session expires (`onSessionExpire`, then `onSignOut`).
107
+
108
+ Under `crossTab: true`, **only the leader tab refreshes**. Refresh tokens are frequently single-use, so electing one refresher avoids a rotation race where two tabs spend the same token; followers receive the rotated record over the channel.
109
+
110
+ ```js
111
+ createAuth({
112
+ adapter,
113
+ crossTab: true,
114
+ refresh: { enabled: true, threshold: 30 },
115
+ });
116
+ ```
117
+
118
+ If the access token is a JWT and the adapter omits `expiresAt`, it is derived from the token's `exp` claim automatically.
119
+
120
+ ## Persistence
121
+
122
+ Persistence is delegated to `@zakkster/lite-persist`: a synchronous boot read hydrates the session before your first paint, and writes are debounced through to storage. Choose the backend with `storage`:
123
+
124
+ - `"localStorage"` (default) survives restarts.
125
+ - `"sessionStorage"` lasts for the tab session.
126
+ - `"memory"` keeps nothing on disk (SSR, tests, or strict session-only flows).
127
+ - any Web-Storage-shaped object for a custom backend.
128
+
129
+ A corrupt stored value is discarded (not thrown) and surfaces as an `AuthError` with code `storage`; a failed write (for example a full quota) is swallowed and the session simply stays live in memory.
130
+
131
+ ## Error handling
132
+
133
+ A hybrid model keeps imperative call sites honest while not letting background failures throw into the void:
134
+
135
+ - Awaited actions (`signIn`, `refresh`) reject with a typed `AuthError`.
136
+ - Ambient failures (background refresh, storage, cross-tab) update the `error` signal and call the `onError` hook.
137
+ - `signOut` is optimistic and local-first: it clears immediately, never rejects, and a failed server-side revoke never resurrects the session.
138
+
139
+ ```js
140
+ try {
141
+ await auth.signIn(creds);
142
+ } catch (e) {
143
+ if (e.code === "invalid_credentials") showBadPassword();
144
+ else if (e.code === "network") showOffline();
145
+ }
146
+
147
+ auth.error.subscribe((e) => { if (e) toast(e.message); });
148
+ ```
149
+
150
+ `AuthError.code` is one of: `invalid_credentials`, `network`, `refresh_failed`, `no_refresh_token`, `expired`, `storage`, `aborted`, `misconfigured`.
151
+
152
+ ## Custom adapters
153
+
154
+ `fetchAdapter` covers the common REST case. For anything else, implement the small adapter contract directly:
155
+
156
+ ```js
157
+ const adapter = {
158
+ async signIn(credentials, signal) {
159
+ // return { user, accessToken, refreshToken?, expiresAt? }
160
+ },
161
+ async refresh(record, signal) { // optional
162
+ // return a fresh record
163
+ },
164
+ async signOut(record) { // optional
165
+ // revoke server-side; throwing here never resurrects the local session
166
+ },
167
+ };
168
+ ```
169
+
170
+ `signal` is an `AbortSignal` that fires when a call is superseded (a newer `signIn`, a `signOut`, or disposal), so a well-behaved adapter can cancel its in-flight request.
171
+
172
+ ### Dynamic headers (token rotation)
173
+
174
+ `fetchAdapter`'s `headers` may be a static object or a getter evaluated on every request. A getter keeps a rotating value, such as an `Authorization` token refreshed in the background, fresh rather than frozen at adapter-construction time:
175
+
176
+ ```js
177
+ fetchAdapter({
178
+ signInUrl: "/api/login",
179
+ refreshUrl: "/api/refresh",
180
+ // re-read on every request, so a rotated token is never stale
181
+ headers: () => ({ Authorization: `Bearer ${auth.token.peek() ?? ""}` }),
182
+ });
183
+ ```
184
+
185
+ If you pass a plain object instead, its values are captured once; for any value that changes over the session (most commonly the access token), use the getter form or apply the token through your own request layer.
186
+
187
+ ## A note on encrypted-cookie / httpOnly sessions
188
+
189
+ httpOnly or encrypted-cookie sessions are not outdated; they are arguably more XSS-resistant, because the token never enters JavaScript. They are a different architecture, not one this library replaces. lite-auth targets token-in-JS single-page apps because a client-side reactive library can only be reactive over state the client can actually see, and an httpOnly token is invisible to JS by design.
190
+
191
+ lite-auth can still serve the cookie model as a session mode: the token never touches storage, `signIn` posts credentials and the server sets the cookie, boot hydrates the user from a `/me` endpoint, `signOut` hits a logout endpoint, and refresh happens server-side. That pairing is in fact the most secure companion to cross-tab logout, because the only thing synced across tabs is user identity, with zero secrets on the wire. This is a planned follow-on via a `restore` adapter hook (boot hydration is storage-only today).
192
+
193
+ ## API
194
+
195
+ - `createAuth(config) -> Auth` builds the controller. See `Auth.d.ts` for the full typed surface.
196
+ - `fetchAdapter(options) -> AuthAdapter` a REST adapter over fetch.
197
+ - `decodeJwtExp(token) -> number | undefined` reads a JWT `exp` as epoch ms.
198
+ - `AuthError` the typed error class.
199
+
200
+ The `Auth` object exposes the six read-only signals above, a `ready` promise, the actions `signIn` / `signOut` / `refresh`, the hook registrars `onSignIn` / `onSignOut` / `onSessionExpire` / `onTokenRefresh` (each returns an idempotent disposer), and `dispose()`. Disposing tears down timers, subscriptions, and the channel but leaves stored data intact (it is not a sign-out).
201
+
202
+ ## Compatibility note
203
+
204
+ lite-auth syncs a serialized string across tabs rather than the record object. `lite-channel` applies inbound values inside a lite-signal `batch()`, which defers subscriber notification; that timing defeats the library's reference-based echo suppression for object values (each cross-tab hop is a fresh reference). A string wire dedupes by value, so an echo terminates after a single hop. This is an implementation detail, but it is the reason the wire payload is a string.
205
+
206
+ ## License
207
+
208
+ MIT (c) Zahary Shinikchiev. See [LICENSE](./LICENSE).
package/llms.txt ADDED
@@ -0,0 +1,114 @@
1
+ # @zakkster/lite-auth
2
+
3
+ > Session-as-a-signal authentication for the lite-* ecosystem. The entire auth
4
+ > state is one reactive value (a private signal holding a SessionRecord or
5
+ > undefined); session, isAuthenticated, token, expiresAt, status and error are
6
+ > computed projections of it. Cross-tab logout/login over BroadcastChannel,
7
+ > durable persistence, optional leader-only token refresh. Zero runtime deps.
8
+
9
+ ## Install
10
+
11
+ npm install @zakkster/lite-auth @zakkster/lite-signal @zakkster/lite-persist
12
+ Optional (only with crossTab:true): npm install @zakkster/lite-channel
13
+
14
+ Peers: @zakkster/lite-signal (required), @zakkster/lite-persist (required),
15
+ @zakkster/lite-channel (optional, lazy-loaded when crossTab:true).
16
+ ESM only. Single file: Auth.js. Types: Auth.d.ts.
17
+
18
+ ## Exports
19
+
20
+ - createAuth(config) -> Auth
21
+ - fetchAdapter(options) -> AuthAdapter
22
+ - decodeJwtExp(token: string) -> number | undefined
23
+ - class AuthError extends Error { code, cause? }
24
+
25
+ ## createAuth(config)
26
+
27
+ config:
28
+ - adapter: AuthAdapter (REQUIRED). { signIn(creds, signal)->Promise<record>,
29
+ refresh?(record, signal)->Promise<record>, signOut?(record)->Promise<void> }
30
+ - storage: "localStorage" (default) | "sessionStorage" | "memory" | StorageLike
31
+ - storageKey: string (default "lite-auth.session.v1"); also the default channel key
32
+ - channelName: string (default = storageKey)
33
+ - crossTab: boolean (default false); requires @zakkster/lite-channel
34
+ - refresh: { enabled: boolean, threshold?: number /* seconds, default 60 */ }
35
+ - channelOptions: object forwarded to lite-channel createTabSync (persist forced off)
36
+ - registry: custom lite-signal registry { signal, computed, batch }
37
+ (advanced; pairs best with storage:"memory" because lite-persist uses the global graph)
38
+ - onSignIn(user) / onSignOut() / onSessionExpire() / onTokenRefresh(record): hooks
39
+ - onError(error: AuthError): ambient (non-thrown) failures
40
+
41
+ returns Auth:
42
+ - session: ReadonlySignal<User | null>
43
+ - isAuthenticated: ReadonlySignal<boolean>
44
+ - token: ReadonlySignal<string | null>
45
+ - expiresAt: ReadonlySignal<number | null> // epoch ms
46
+ - status: ReadonlySignal<"idle" | "authenticating" | "refreshing">
47
+ - error: ReadonlySignal<AuthError | null>
48
+ - ready: Promise<void> // resolves after boot hydration + channel attach + boot refresh
49
+ - signIn(credentials) -> Promise<User> // throws AuthError on failure
50
+ - signOut() -> Promise<void> // optimistic, never throws
51
+ - refresh() -> Promise<void> // throws AuthError on failure
52
+ - onSignIn/onSignOut/onSessionExpire/onTokenRefresh(fn) -> () => void // idempotent disposer
53
+ - dispose() -> void // tears down timers/subscriptions/channel; leaves storage intact
54
+
55
+ A ReadonlySignal<T> is callable (tracked read), has .peek() (untracked) and
56
+ .subscribe(fn) -> dispose (fires immediately, then on change).
57
+
58
+ ## SessionRecord
59
+
60
+ { user: any, accessToken: string, refreshToken?: string, expiresAt?: number /* epoch ms */ }
61
+ The user value is opaque to lite-auth.
62
+
63
+ ## AuthError.code values
64
+
65
+ invalid_credentials | network | refresh_failed | no_refresh_token | expired |
66
+ storage | aborted | misconfigured
67
+
68
+ ## Behaviour
69
+
70
+ - Boot hydration is synchronous: a stored session is available before ready resolves.
71
+ - A restored session is the baseline; onSignIn does NOT fire on restore.
72
+ - Empty state is `undefined` internally so lite-persist removes the key on sign-out;
73
+ projections still surface null.
74
+ - Persistence: lite-persist with syncTabs:false (lite-channel owns cross-tab),
75
+ debounce:0, flushOnDispose:true. Corrupt stored value -> discarded + error("storage").
76
+ Write/quota failure -> swallowed; session stays in memory.
77
+ - Cross-tab: lite-channel with persist:false. A serialized STRING is synced (not the
78
+ object) so value-dedupe terminates the echo. signIn/signOut/refresh propagate.
79
+ - Refresh: timer fires threshold seconds before expiresAt. Under crossTab, only the
80
+ leader tab (lite-channel isLeader) refreshes; followers receive the rotated record.
81
+ JWT exp is used when the adapter omits expiresAt.
82
+ - Concurrency: last-call-wins. A superseded signIn/refresh rejects with code "aborted".
83
+ signOut aborts an in-flight refresh and is never resurrected by it.
84
+
85
+ ## fetchAdapter(options)
86
+
87
+ options: { signInUrl, refreshUrl?, signOutUrl?, headers?, parseSession?(json), fetch? }
88
+ POSTs JSON; reads { user, accessToken, refreshToken?, expiresAt? }; derives expiresAt
89
+ from the access token's JWT exp claim when absent.
90
+
91
+ ## Minimal example
92
+
93
+ import { createAuth, fetchAdapter } from "@zakkster/lite-auth";
94
+ const auth = createAuth({
95
+ adapter: fetchAdapter({ signInUrl: "/api/login", refreshUrl: "/api/refresh" }),
96
+ crossTab: true,
97
+ refresh: { enabled: true, threshold: 60 },
98
+ });
99
+ await auth.ready;
100
+ await auth.signIn({ username, password });
101
+ auth.isAuthenticated.subscribe(on => render(on));
102
+ await auth.signOut(); // logs out every tab
103
+
104
+ ## Custom adapter
105
+
106
+ const adapter = {
107
+ async signIn(credentials, signal) { /* return SessionRecord */ },
108
+ async refresh(record, signal) { /* return SessionRecord */ }, // optional
109
+ async signOut(record) { /* revoke server-side */ }, // optional
110
+ };
111
+
112
+ ## License
113
+
114
+ MIT (c) Zahary Shinikchiev.
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@zakkster/lite-auth",
3
+ "version": "1.0.0",
4
+ "description": "Session-as-a-signal authentication for the lite-* ecosystem: a reactive User|null with cross-tab logout, pluggable backends, and leader-only token refresh. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "./Auth.js",
7
+ "types": "./Auth.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./Auth.d.ts",
11
+ "node": "./Auth.js",
12
+ "import": "./Auth.js",
13
+ "default": "./Auth.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "Auth.js",
18
+ "Auth.d.ts",
19
+ "llms.txt",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE"
23
+ ],
24
+ "sideEffects": false,
25
+ "scripts": {
26
+ "test": "node --test --test-reporter=spec test/*.test.mjs",
27
+ "test:gc": "node --expose-gc --test --test-reporter=spec test/*.test.mjs",
28
+ "test:coverage": "c8 node --test --test-reporter=spec test/*.test.mjs",
29
+ "bench": "node bench/bench.mjs",
30
+ "demo:build": "esbuild Auth.js --bundle --format=esm --outfile=demo/lite-auth.bundle.js",
31
+ "verify": "npm test && npm run bench"
32
+ },
33
+ "keywords": [
34
+ "auth",
35
+ "authentication",
36
+ "session",
37
+ "signal",
38
+ "signals",
39
+ "reactive",
40
+ "cross-tab",
41
+ "broadcastchannel",
42
+ "jwt",
43
+ "token-refresh",
44
+ "lite",
45
+ "zero-dependency"
46
+ ],
47
+ "author": "Zahary Shinikchiev",
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/PeshoVurtoleta/lite-auth.git"
52
+ },
53
+ "homepage": "https://github.com/PeshoVurtoleta/lite-auth#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/PeshoVurtoleta/lite-auth/issues"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "funding": {
61
+ "type": "github",
62
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
63
+ },
64
+ "peerDependencies": {
65
+ "@zakkster/lite-signal": "^1.1.2",
66
+ "@zakkster/lite-persist": "^1.0.0",
67
+ "@zakkster/lite-channel": "^1.0.1"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@zakkster/lite-channel": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "devDependencies": {
75
+ "@zakkster/lite-channel": "^1.0.1",
76
+ "@zakkster/lite-persist": "^1.0.0",
77
+ "@zakkster/lite-signal": "^1.1.4",
78
+ "c8": "^11.0.0",
79
+ "esbuild": "^0.28.0"
80
+ }
81
+ }