fetchguard 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 FetchGuard
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,429 @@
1
+ # FetchGuard
2
+
3
+ [![npm version](https://img.shields.io/npm/v/fetchguard.svg)](https://www.npmjs.com/package/fetchguard)
4
+ ![npm bundle size](https://img.shields.io/bundlephobia/min/fetchguard)
5
+ [![npm downloads](https://img.shields.io/npm/dm/fetchguard.svg)](https://www.npmjs.com/package/fetchguard)
6
+ [![license](https://img.shields.io/npm/l/fetchguard.svg)](https://github.com/minhtaimc/fetchguard/blob/main/LICENSE)
7
+
8
+
9
+ FetchGuard is a secure, type-safe API client that runs your network requests inside a Web Worker. Access tokens never touch the main thread, reducing XSS risk while providing automatic token refresh, domain allow-listing, and a clean Result-based API.
10
+
11
+ ## Why FetchGuard
12
+
13
+ - XSS hardening via closure: tokens live inside a Worker IIFE closure, not in `window`, not in JS-readable cookies, and not in local/session storage.
14
+ - Proactive refresh: refresh before expiry to avoid 401s and flapping UIs.
15
+ - Modular providers: compose Storage + Parser + Strategy or use presets.
16
+ - Type-safe results: powered by `ts-micro-result` (no try/catch pyramid).
17
+ - Public endpoints: opt out per request with `requiresAuth: false`.
18
+ - Domain allow-list: block requests to unexpected hosts (wildcards and ports supported).
19
+
20
+ ## Architecture (Simplified)
21
+
22
+ ```
23
+ +--------------------+ postMessage +-------------------------+ HTTPS +------------------+
24
+ | Main App (UI) | requests/results only | Web Worker (Sandbox) | fetch with auth | Backend API |
25
+ | - React/Vue/... | -----------------------> | - IIFE closure tokens | -----------------> | - Auth endpoints|
26
+ | - No token access | <----------------------- | - Domain allow-list | <----------------- | - JSON payloads |
27
+ +--------------------+ data only (no tokens) +-------------------------+ responses only +------------------+
28
+ ```
29
+
30
+ - Setup once: app configures provider; worker is ready.
31
+ - Login: worker calls BE, parses tokens, stores inside closure (never exposed).
32
+ - Authenticated request: worker ensures token, adds Authorization, calls BE, returns data only.
33
+ - Public request: `requiresAuth: false` skips token injection.
34
+
35
+ Security highlight: Web Worker sandbox + IIFE closure ensures tokens never appear in `window`, storage, or any message payload.
36
+
37
+ ## Closure Isolation
38
+
39
+ Inside the worker, tokens live in a lexical closure created by an IIFE. They are not properties on `self`, not exported, and never posted back.
40
+
41
+ ```ts
42
+ // worker.ts (simplified structure)
43
+ ;(function () {
44
+ let accessToken: string | null = null
45
+ let refreshToken: string | null = null
46
+ let expiresAt: number | null = null
47
+ let currentUser: unknown | undefined
48
+
49
+ self.onmessage = async (event) => {
50
+ // Handle SETUP/FETCH/AUTH_CALL/CANCEL/PING here
51
+ // Only status/body/headers or auth state get posted back
52
+ }
53
+ })()
54
+ ```
55
+
56
+ Practical effects:
57
+ - Scripts running in the main thread cannot read tokens (they are not in global memory or storage).
58
+ - `postMessage` payloads never include tokens; `AUTH_STATE_CHANGED` emits booleans/timestamps/user only.
59
+ - Even if an attacker obtains the `Worker` instance, there is no API surface to retrieve tokens.
60
+
61
+ ## Token Storage
62
+
63
+ - Access token: worker memory only (closure; not readable by the main thread).
64
+ - Refresh token:
65
+ - Cookie provider: httpOnly cookie managed by the browser (never readable by JS).
66
+ - Body provider: persisted in IndexedDB via `createIndexedDBStorage` for session continuity.
67
+
68
+ ## Security Model
69
+
70
+ Helps with:
71
+ - Prevents scripts in the main thread from reading tokens directly (no tokens in `window`, `localStorage`, or JS-readable cookies).
72
+ - Reduces token exfiltration risk by restricting worker fetches to an allow-listed set of domains.
73
+ - Avoids 401-triggered race conditions by refreshing early inside the worker.
74
+
75
+ Out of scope (still recommended to address):
76
+ - A fully compromised app can still ask the worker to perform actions on the user’s behalf.
77
+ - Malicious browser extensions or devtools can subvert runtime.
78
+ - Build-time or supply-chain tampering can alter provider code before it reaches the worker.
79
+
80
+ Hardening tips:
81
+ - Enable strict CSP and Trusted Types where applicable.
82
+ - Serve over HTTPS; set secure cookie attributes (httpOnly, SameSite, Secure) when using cookies.
83
+ - Rotate refresh tokens on every refresh/login and invalidate older tokens server-side; prefer one-time-use refresh tokens to limit replay risk.
84
+ - Keep tokens short-lived; rely on refresh tokens and server-side revocation.
85
+ - Use the domain allow-list aggressively (include explicit ports in development).
86
+ - Avoid logging or exposing tokens in any responses; keep login/refresh parsing inside the worker.
87
+
88
+ ## Installation
89
+
90
+ ```bash
91
+ npm install fetchguard ts-micro-result
92
+ # pnpm add fetchguard ts-micro-result
93
+ # yarn add fetchguard ts-micro-result
94
+ ```
95
+
96
+ Peer dependency: `ts-micro-result`.
97
+
98
+ ## Quick Start
99
+
100
+ Pick a provider that matches your backend. For SPAs that return tokens in the response body, use the `body-auth` provider. For SSR/httpOnly cookie flows, use the `cookie-auth` provider.
101
+
102
+ ```ts
103
+ import { createClient } from 'fetchguard'
104
+
105
+ const api = createClient({
106
+ provider: {
107
+ type: 'body-auth', // or 'cookie-auth'
108
+ refreshUrl: 'https://api.example.com/auth/refresh',
109
+ loginUrl: 'https://api.example.com/auth/login',
110
+ logoutUrl: 'https://api.example.com/auth/logout'
111
+ },
112
+ allowedDomains: ['api.example.com', '*.cdn.example.com']
113
+ })
114
+
115
+ type User = { id: string; name: string }
116
+ const res = await api.get<User[]>('https://api.example.com/users')
117
+
118
+ if (res.isOk()) {
119
+ console.log('Users:', res.data)
120
+ } else {
121
+ console.error('Error:', res.errors?.[0])
122
+ }
123
+
124
+ // Cleanup
125
+ api.destroy()
126
+ ```
127
+
128
+ ### Login / Logout
129
+
130
+ ```ts
131
+ // Perform login; worker stores tokens and emits an auth event
132
+ await api.login({ email: 'user@example.com', password: 'password123' })
133
+
134
+ // Subscribe to auth state changes
135
+ const unsubscribe = api.onAuthStateChanged(({ authenticated, expiresAt, user }) => {
136
+ console.log('Auth:', authenticated, 'exp:', expiresAt, 'user:', user)
137
+ })
138
+
139
+ // Later
140
+ await api.logout()
141
+ unsubscribe()
142
+ ```
143
+
144
+ ### Public Endpoints and Headers
145
+
146
+ ```ts
147
+ // Skip auth for public endpoints
148
+ await api.get('/public/config', { requiresAuth: false })
149
+
150
+ // Include response headers in the result
151
+ const r = await api.get('/profile', { includeHeaders: true })
152
+ if (r.isOk()) {
153
+ console.log(r.status, r.headers, r.data)
154
+ }
155
+ ```
156
+
157
+
158
+ ### Cancellation
159
+
160
+ ```ts
161
+ const { id, result, cancel } = api.fetchWithId('/slow')
162
+ // ... some time later
163
+ cancel()
164
+
165
+ const rr = await result // rejects with a cancellation error
166
+ ```
167
+
168
+ ## Provider System (Composable)
169
+
170
+ Providers are composed from three parts:
171
+
172
+ - Storage: where refresh tokens persist (e.g., IndexedDB) or none for cookie flows
173
+ - Parser: how to parse tokens from backend responses
174
+ - Strategy: how to call refresh/login/logout endpoints
175
+
176
+ ```ts
177
+ import {
178
+ createProvider,
179
+ createIndexedDBStorage,
180
+ bodyParser,
181
+ createBodyStrategy
182
+ } from 'fetchguard'
183
+
184
+ const provider = createProvider({
185
+ // Persist refresh tokens across reloads
186
+ refreshStorage: createIndexedDBStorage('MyAppDB', 'refreshToken'),
187
+ // Parse tokens from JSON body
188
+ parser: bodyParser,
189
+ // Call auth endpoints with tokens in request body
190
+ strategy: createBodyStrategy({
191
+ refreshUrl: '/auth/refresh',
192
+ loginUrl: '/auth/login',
193
+ logoutUrl: '/auth/logout'
194
+ })
195
+ })
196
+
197
+ ```
198
+
199
+ ### Preset Providers
200
+
201
+ FetchGuard provides two built-in auth strategies:
202
+
203
+ **1. Cookie Auth** (SSR/httpOnly cookies)
204
+
205
+ Best for server-side rendered apps where tokens are managed via httpOnly cookies.
206
+
207
+ ```ts
208
+ const api = createClient({
209
+ provider: {
210
+ type: 'cookie-auth',
211
+ refreshUrl: 'https://api.example.com/auth/refresh',
212
+ loginUrl: 'https://api.example.com/auth/login',
213
+ logoutUrl: 'https://api.example.com/auth/logout'
214
+ },
215
+ allowedDomains: ['api.example.com']
216
+ })
217
+ ```
218
+
219
+ **2. Body Auth** (SPA with IndexedDB)
220
+
221
+ Best for single-page apps where tokens are returned in response body and persisted to IndexedDB.
222
+
223
+ ```ts
224
+ const api = createClient({
225
+ provider: {
226
+ type: 'body-auth',
227
+ refreshUrl: 'https://api.example.com/auth/refresh',
228
+ loginUrl: 'https://api.example.com/auth/login',
229
+ logoutUrl: 'https://api.example.com/auth/logout',
230
+ refreshTokenKey: 'refreshToken' // Optional, defaults to 'refreshToken'
231
+ },
232
+ allowedDomains: ['api.example.com']
233
+ })
234
+ ```
235
+
236
+ ### Advanced: Custom Providers via Registry
237
+
238
+ For complex auth flows, you can create custom providers and register them:
239
+
240
+ ```ts
241
+ import { registerProvider, createClient, createProvider } from 'fetchguard'
242
+ import { createIndexedDBStorage } from 'fetchguard'
243
+ import { ok } from 'ts-micro-result'
244
+
245
+ // Create custom provider
246
+ const myProvider = createProvider({
247
+ refreshStorage: createIndexedDBStorage('MyApp', 'refreshToken'),
248
+ parser: {
249
+ async parse(response: Response) {
250
+ const data = await response.json()
251
+ return {
252
+ token: data.accessToken,
253
+ refreshToken: data.refreshToken,
254
+ expiresAt: data.expiresAt,
255
+ user: data.user
256
+ }
257
+ }
258
+ },
259
+ strategy: {
260
+ async refreshToken(refreshToken: string | null) {
261
+ const res = await fetch('https://api.example.com/auth/refresh', {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ refreshToken })
265
+ })
266
+ return res
267
+ },
268
+ async login(payload: unknown) {
269
+ const res = await fetch('https://api.example.com/auth/login', {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ body: JSON.stringify(payload)
273
+ })
274
+ return res
275
+ },
276
+ async logout(payload?: unknown) {
277
+ const res = await fetch('https://api.example.com/auth/logout', {
278
+ method: 'POST',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify(payload || {})
281
+ })
282
+ return res
283
+ }
284
+ }
285
+ })
286
+
287
+ // Register provider
288
+ registerProvider('my-custom-auth', myProvider)
289
+
290
+ // Use registered provider
291
+ const api = createClient({
292
+ provider: 'my-custom-auth', // Reference by name
293
+ allowedDomains: ['api.example.com']
294
+ })
295
+ ```
296
+
297
+
298
+ ### Custom Auth Methods (Advanced)
299
+
300
+ You can add custom auth methods to your provider strategy and call them via `api.call(methodName, ...args)`:
301
+
302
+ ```ts
303
+ import { createProvider, createIndexedDBStorage, bodyParser } from 'fetchguard'
304
+ import { ok } from 'ts-micro-result'
305
+
306
+ const myProvider = createProvider({
307
+ refreshStorage: createIndexedDBStorage('MyApp', 'refreshToken'),
308
+ parser: bodyParser,
309
+ strategy: {
310
+ // Standard methods
311
+ async refreshToken(refreshToken: string | null) { /* ... */ },
312
+ async login(payload: unknown) { /* ... */ },
313
+ async logout(payload?: unknown) { /* ... */ },
314
+
315
+ // Custom method - OTP login
316
+ async loginWithOTP(payload: { phone: string; code: string }) {
317
+ const res = await fetch('https://api.example.com/auth/login/otp', {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify(payload)
321
+ })
322
+ return res
323
+ }
324
+ }
325
+ })
326
+
327
+ // Register and use
328
+ registerProvider('otp-auth', myProvider)
329
+ const api = createClient({ provider: 'otp-auth' })
330
+
331
+ // Call custom method
332
+ await api.call('loginWithOTP', { phone: '+1234567890', code: '123456' })
333
+ ```
334
+
335
+ ## Domain Allow-List
336
+
337
+ Limit requests to known hosts. Supports wildcards and optional ports.
338
+
339
+ ```ts
340
+ allowedDomains: [
341
+ 'api.example.com', // exact host
342
+ '*.example.com', // any subdomain
343
+ 'localhost:5173' // include port match
344
+ ]
345
+ ```
346
+
347
+ ## Bundler Notes
348
+
349
+ FetchGuard creates a Worker using `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })`.
350
+
351
+ - Vite/Rollup: supported out of the box
352
+ - Webpack 5: supported via `new URL()` ESM pattern
353
+ - Older toolchains may require a custom worker loader
354
+
355
+ The library targets modern browsers with Web Worker and (optionally) IndexedDB support.
356
+
357
+ ## API Reference
358
+
359
+ ### `createClient(options)`
360
+
361
+ - `provider`: `ProviderPresetConfig | string` (required)
362
+ - Config object: `{ type: 'cookie-auth' | 'body-auth', refreshUrl, loginUrl, logoutUrl, ... }`
363
+ - String: Registered provider name
364
+ - `allowedDomains?`: `string[]` - Domain whitelist (supports wildcards)
365
+ - `debug?`: `boolean` - Enable debug logging in worker
366
+ - `refreshEarlyMs?`: `number` - Refresh token X ms before expiry (default: 60000)
367
+ - `defaultTimeoutMs?`: `number` - Request timeout (default: 120000)
368
+ - `retryCount?`: `number` - Retry failed requests (default: 3)
369
+ - `retryDelayMs?`: `number` - Delay between retries (default: 1000)
370
+
371
+ - FetchGuardClient
372
+ - fetch(url, options?): Result<ApiResponse<T>>
373
+ - get/post/put/patch/delete(...): Result<ApiResponse<T>>
374
+ - fetchWithId(url, options?): { id, result, cancel }
375
+ - cancel(id)
376
+ - init(payload?): Result<{ initialized: true; authenticated: boolean; expiresAt?: number | null; user?: unknown }>
377
+ - login(payload?): Result<void>
378
+ - logout(payload?): Result<void>
379
+ - call(method: string, ...args): Result<void> // for custom provider methods
380
+ - onAuthStateChanged(cb): () => void
381
+ - ping(): Result<{ timestamp: number }>
382
+ - destroy(): void
383
+
384
+ Types:
385
+
386
+ - ApiResponse<T> = { data: T; status: number; headers: Record<string, string> }
387
+ - FetchGuardRequestInit extends RequestInit with:
388
+ - requiresAuth?: boolean // default true
389
+ - includeHeaders?: boolean // default false
390
+
391
+ ## Message Protocol (pairs, summary)
392
+
393
+ - Main -> Worker: SETUP -> Worker -> Main: READY
394
+ - Main -> Worker: FETCH -> Worker -> Main: FETCH_RESULT | FETCH_ERROR
395
+ - Main -> Worker: AUTH_CALL(login/logout/...) -> Worker -> Main: RESULT (and AUTH_STATE_CHANGED event)
396
+ - Main -> Worker: CANCEL -> aborts in-worker fetch (no explicit response)
397
+ - Main -> Worker: PING -> Worker -> Main: PONG
398
+
399
+ ## Error Handling
400
+
401
+ All methods return a `Result<T>` from `ts-micro-result`.
402
+
403
+ ```ts
404
+ const res = await api.get('/users')
405
+ if (res.isOk()) {
406
+ console.log(res.data)
407
+ } else {
408
+ const err = res.errors?.[0]
409
+ console.warn(err?.code, err?.message)
410
+ }
411
+ ```
412
+
413
+ Grouped error helpers are exported: `GeneralErrors`, `InitErrors`, `AuthErrors`, `DomainErrors`, `NetworkErrors`, `RequestErrors`.
414
+
415
+ ## Roadmap
416
+
417
+ - SSE streaming support
418
+ - Upload progress
419
+ - Interceptors
420
+ - Advanced retries (exponential backoff)
421
+ - Offline queueing
422
+
423
+ ## License
424
+
425
+ MIT - see `LICENSE`.
426
+
427
+ ---
428
+
429
+ Made for secure, resilient frontend API calls.