@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 +191 -0
- package/Auth.js +720 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/llms.txt +114 -0
- package/package.json +81 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-auth)
|
|
6
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-auth)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-auth)
|
|
9
|
+
[](https://www.npmjs.com/package/@zakkster/lite-auth)
|
|
10
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+
[](./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<SessionRecord | undefined>"]
|
|
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
|
+
}
|