@fresho/router 0.0.1 → 0.1.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.
@@ -0,0 +1,385 @@
1
+ # Authentication
2
+
3
+ @fresho/router provides flexible authentication support for both token-based and cookie-based auth flows.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Cookie-Based Auth](#cookie-based-auth)
9
+ - [Token-Based Auth](#token-based-auth)
10
+ - [Token Refresh](#token-refresh)
11
+ - [API Reference](#api-reference)
12
+ - [jwtSign](#jwtsign)
13
+ - [jwtAuth](#jwtauth)
14
+
15
+ ---
16
+
17
+ ## Overview
18
+
19
+ There are two main approaches to authentication:
20
+
21
+ | Approach | Best For | Token Storage | Client Complexity |
22
+ |----------|----------|---------------|-------------------|
23
+ | **Cookies** | Web apps (same-origin) | httpOnly cookie (server-managed) | Minimal |
24
+ | **Bearer tokens** | SPAs, mobile apps, cross-origin | Memory or secure storage | Moderate |
25
+
26
+ Both approaches use the same server-side middleware (`jwtAuth`) - the difference is where the token is stored and how it's sent.
27
+
28
+ ---
29
+
30
+ ## Cookie-Based Auth
31
+
32
+ The simplest approach for web applications. The server sets an httpOnly cookie, and the browser automatically sends it with every request.
33
+
34
+ ### Server
35
+
36
+ ```typescript
37
+ import { route, router } from '@fresho/router';
38
+ import { jwtAuth, jwtSign } from '@fresho/router/middleware';
39
+
40
+ const JWT_SECRET = process.env.JWT_SECRET!;
41
+
42
+ // Public routes (no auth middleware)
43
+ const publicRoutes = router({
44
+ login: router({
45
+ post: route({
46
+ body: { email: 'string', password: 'string' },
47
+ handler: async (c) => {
48
+ const user = await db.findUser(c.body.email, c.body.password);
49
+ if (!user) {
50
+ return new Response('Invalid credentials', { status: 401 });
51
+ }
52
+
53
+ const token = await jwtSign(
54
+ { email: user.email },
55
+ JWT_SECRET,
56
+ { expiresIn: '7d', subject: user.id }
57
+ );
58
+
59
+ return new Response(JSON.stringify({ success: true }), {
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'Set-Cookie': `token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${7 * 24 * 60 * 60}`,
63
+ },
64
+ });
65
+ },
66
+ }),
67
+ }),
68
+
69
+ logout: router({
70
+ post: async () => {
71
+ return new Response(JSON.stringify({ success: true }), {
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ 'Set-Cookie': 'token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
75
+ },
76
+ });
77
+ },
78
+ }),
79
+ });
80
+
81
+ // Protected routes (with auth middleware)
82
+ const protectedRoutes = router({
83
+ profile: router({
84
+ get: route.ctx<{ user: { id: string; email: string } }>()({
85
+ handler: async (c) => ({
86
+ id: c.user.id,
87
+ email: c.user.email,
88
+ }),
89
+ }),
90
+ }),
91
+ },
92
+ jwtAuth({
93
+ secret: JWT_SECRET,
94
+ claims: (payload) => ({
95
+ user: { id: payload.sub, email: payload.email },
96
+ }),
97
+ })
98
+ );
99
+
100
+ // Combine into API
101
+ const api = router({
102
+ auth: publicRoutes,
103
+ api: protectedRoutes,
104
+ });
105
+
106
+ export default { fetch: api.handler() };
107
+ ```
108
+
109
+ ### Client
110
+
111
+ ```typescript
112
+ import { createHttpClient } from '@fresho/router';
113
+ import type { api } from './server.js';
114
+
115
+ const client = createHttpClient<typeof api>({
116
+ baseUrl: 'https://api.example.com',
117
+ credentials: 'include', // Send cookies with every request
118
+ });
119
+
120
+ // Login - server sets httpOnly cookie
121
+ await client.auth.login.$post({
122
+ body: { email: 'alice@example.com', password: 'secret' },
123
+ });
124
+
125
+ // All subsequent requests automatically include the cookie
126
+ const profile = await client.api.profile();
127
+ console.log(profile.email);
128
+
129
+ // Logout - server clears the cookie
130
+ await client.auth.logout.$post();
131
+ ```
132
+
133
+ ### Cookie Options
134
+
135
+ | Option | Recommended Value | Purpose |
136
+ |--------|-------------------|---------|
137
+ | `HttpOnly` | Always | Prevents JavaScript access (XSS protection) |
138
+ | `Secure` | Always in production | Only send over HTTPS |
139
+ | `SameSite` | `Strict` or `Lax` | CSRF protection |
140
+ | `Path` | `/` | Cookie scope |
141
+ | `Max-Age` | Match token expiry | Cookie lifetime in seconds |
142
+
143
+ ---
144
+
145
+ ## Token-Based Auth
146
+
147
+ For SPAs, mobile apps, or cross-origin requests where cookies don't work well.
148
+
149
+ ### Server
150
+
151
+ Same as cookie-based, but return the token in the response body instead of a cookie:
152
+
153
+ ```typescript
154
+ login: router({
155
+ post: route({
156
+ body: { email: 'string', password: 'string' },
157
+ handler: async (c) => {
158
+ const user = await db.findUser(c.body.email, c.body.password);
159
+ if (!user) {
160
+ return new Response('Invalid credentials', { status: 401 });
161
+ }
162
+
163
+ const token = await jwtSign(
164
+ { email: user.email },
165
+ JWT_SECRET,
166
+ { expiresIn: '1h', subject: user.id }
167
+ );
168
+
169
+ return { token };
170
+ },
171
+ }),
172
+ })
173
+ ```
174
+
175
+ ### Client
176
+
177
+ Use dynamic headers to include the token:
178
+
179
+ ```typescript
180
+ import { createHttpClient } from '@fresho/router';
181
+ import type { api } from './server.js';
182
+
183
+ // Token storage (use your state management in practice)
184
+ let token: string | null = null;
185
+
186
+ const client = createHttpClient<typeof api>({
187
+ baseUrl: 'https://api.example.com',
188
+ headers: {
189
+ // Dynamic header - called on each request
190
+ 'Authorization': () => token ? `Bearer ${token}` : null,
191
+ },
192
+ });
193
+
194
+ // Login - store token in memory
195
+ const result = await client.auth.login.$post({
196
+ body: { email: 'alice@example.com', password: 'secret' },
197
+ });
198
+ token = result.token;
199
+
200
+ // Subsequent requests include the Authorization header
201
+ const profile = await client.api.profile();
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Token Refresh
207
+
208
+ To avoid forcing users to re-login when tokens expire, implement a refresh flow.
209
+
210
+ ### Proactive Refresh
211
+
212
+ Refresh the token before it expires using an async header function:
213
+
214
+ ```typescript
215
+ import { jwtSign } from '@fresho/router/middleware';
216
+
217
+ let token: string | null = null;
218
+ let tokenExp: number = 0;
219
+
220
+ // Parse expiry from JWT (simplified - use a proper JWT decode in production)
221
+ function getTokenExp(jwt: string): number {
222
+ const payload = JSON.parse(atob(jwt.split('.')[1]));
223
+ return payload.exp * 1000;
224
+ }
225
+
226
+ const client = createHttpClient<typeof api>({
227
+ baseUrl: 'https://api.example.com',
228
+ headers: {
229
+ 'Authorization': async () => {
230
+ if (!token) return null;
231
+
232
+ // Refresh if token expires in < 5 minutes
233
+ if (tokenExp - Date.now() < 5 * 60 * 1000) {
234
+ const result = await client.api.refresh.$post();
235
+ token = result.token;
236
+ tokenExp = getTokenExp(token);
237
+ }
238
+
239
+ return `Bearer ${token}`;
240
+ },
241
+ },
242
+ });
243
+ ```
244
+
245
+ ### Server-Side Refresh Endpoint
246
+
247
+ ```typescript
248
+ refresh: router({
249
+ post: route.ctx<{ user: { id: string; email: string } }>()({
250
+ handler: async (c) => {
251
+ // Issue a new token with fresh expiry
252
+ const token = await jwtSign(
253
+ { email: c.user.email },
254
+ JWT_SECRET,
255
+ { expiresIn: '1h', subject: c.user.id }
256
+ );
257
+ return { token };
258
+ },
259
+ }),
260
+ })
261
+ ```
262
+
263
+ ---
264
+
265
+ ## API Reference
266
+
267
+ ### jwtSign
268
+
269
+ Signs a JWT token using Web Crypto API. Compatible with Cloudflare Workers, Deno, and browsers.
270
+
271
+ ```typescript
272
+ import { jwtSign } from '@fresho/router/middleware';
273
+
274
+ const token = await jwtSign(
275
+ { email: 'user@example.com', role: 'admin' }, // Custom claims
276
+ 'your-secret-key', // Secret
277
+ {
278
+ algorithm: 'HS256', // HS256, HS384, or HS512 (default: HS256)
279
+ expiresIn: '7d', // Token lifetime
280
+ subject: 'user-123', // sub claim (typically user ID)
281
+ issuer: 'my-app', // iss claim
282
+ audience: 'my-api', // aud claim
283
+ }
284
+ );
285
+ ```
286
+
287
+ **Options:**
288
+
289
+ | Option | Type | Description |
290
+ |--------|------|-------------|
291
+ | `algorithm` | `'HS256' \| 'HS384' \| 'HS512'` | Signing algorithm (default: `'HS256'`) |
292
+ | `expiresIn` | `string \| number` | Token lifetime: `'1h'`, `'7d'`, `'30m'`, or seconds |
293
+ | `notBefore` | `string \| number` | Token not valid until this time from now |
294
+ | `subject` | `string` | `sub` claim (typically user ID) |
295
+ | `issuer` | `string` | `iss` claim |
296
+ | `audience` | `string \| string[]` | `aud` claim |
297
+ | `issuedAt` | `number \| Date` | Custom `iat` (defaults to now) |
298
+
299
+ **Duration formats:** `'60s'`, `'30m'`, `'1h'`, `'7d'`, `'2w'`
300
+
301
+ ### jwtVerify
302
+
303
+ Verifies a JWT token and returns the payload. Use this when you need to verify tokens outside of the middleware context.
304
+
305
+ ```typescript
306
+ import { jwtVerify } from '@fresho/router/middleware';
307
+
308
+ try {
309
+ const payload = await jwtVerify(token, 'your-secret-key');
310
+ console.log(payload.sub); // user ID
311
+ console.log(payload.exp); // expiration timestamp
312
+ } catch (error) {
313
+ console.error('Invalid token:', error.message);
314
+ }
315
+
316
+ // With specific algorithms
317
+ const payload = await jwtVerify(token, secret, { algorithms: ['HS512'] });
318
+ ```
319
+
320
+ **Options:**
321
+
322
+ | Option | Type | Description |
323
+ |--------|------|-------------|
324
+ | `algorithms` | `JwtAlgorithm[]` | Allowed algorithms (default: `['HS256']`) |
325
+
326
+ **Throws:**
327
+ - `'Invalid JWT format'` - Token doesn't have 3 parts
328
+ - `'Unsupported algorithm'` - Token uses algorithm not in allowed list
329
+ - `'Invalid signature'` - Signature verification failed
330
+ - `'Token expired'` - Token's `exp` claim is in the past
331
+ - `'Token not yet valid'` - Token's `nbf` claim is in the future
332
+
333
+ ### jwtAuth
334
+
335
+ Middleware that verifies JWT tokens from the Authorization header or cookies.
336
+
337
+ ```typescript
338
+ import { jwtAuth } from '@fresho/router/middleware';
339
+
340
+ jwtAuth({
341
+ // Required: secret key
342
+ secret: 'your-secret-key',
343
+ // Or from environment:
344
+ // secret: (ctx) => ctx.env.JWT_SECRET,
345
+
346
+ // Required: map JWT payload to context properties
347
+ claims: (payload) => ({
348
+ user: {
349
+ id: payload.sub,
350
+ email: payload.email,
351
+ },
352
+ }),
353
+
354
+ // Optional: allowed algorithms (default: ['HS256'])
355
+ algorithms: ['HS256', 'HS384', 'HS512'],
356
+
357
+ // Optional: custom token extraction (default: Authorization header or 'token' cookie)
358
+ getToken: (request) => {
359
+ return request.headers.get('X-Auth-Token');
360
+ },
361
+ });
362
+ ```
363
+
364
+ **Token extraction order (default):**
365
+ 1. `Authorization: Bearer <token>` header
366
+ 2. `token` cookie
367
+
368
+ **Rejecting tokens:**
369
+
370
+ Return `null` from the `claims` function to reject a valid token:
371
+
372
+ ```typescript
373
+ claims: (payload) => {
374
+ // Only allow admin users
375
+ if (payload.role !== 'admin') return null;
376
+ return { user: { id: payload.sub } };
377
+ },
378
+ ```
379
+
380
+ ---
381
+
382
+ ## See Also
383
+
384
+ - [Middleware Documentation](middleware.md)
385
+ - [Main Documentation](../README.md)