@fresho/router 0.0.1 → 0.0.4
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 +21 -0
- package/README.md +401 -28
- package/dist/index.d.mts +377 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1046 -0
- package/dist/index.mjs.map +1 -0
- package/dist/middleware/index.d.mts +396 -0
- package/dist/middleware/index.d.mts.map +1 -0
- package/dist/middleware/index.mjs +675 -0
- package/dist/middleware/index.mjs.map +1 -0
- package/dist/middleware-BpFh0XrR.mjs +50 -0
- package/dist/middleware-BpFh0XrR.mjs.map +1 -0
- package/dist/middleware-CqtYOc6D.d.mts +382 -0
- package/dist/middleware-CqtYOc6D.d.mts.map +1 -0
- package/docs/authentication.md +385 -0
- package/docs/middleware.md +441 -0
- package/package.json +63 -5
|
@@ -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)
|