ideal-auth 0.2.0 → 0.3.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/.claude-plugin/plugin.json +5 -0
- package/README.md +14 -2
- package/dist/auth-instance.d.ts +2 -1
- package/dist/auth-instance.js +30 -2
- package/dist/auth.js +10 -0
- package/dist/session/seal.js +6 -1
- package/dist/types.d.ts +22 -1
- package/package.json +4 -2
- package/skills/ideal-auth/SKILL.md +2000 -0
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ideal-auth
|
|
3
|
+
description: Auth primitives for the JS ecosystem. Covers login, logout, session, register, signup, sign in, sign out, password, 2FA, two-factor, TOTP, MFA, ideal-auth, cookie bridge, middleware, route protection, rate limit, rate limiting, password reset, forgot password, email verification, remember me, recovery code, CSRF, encrypt, decrypt, hash, bcrypt, token, secret, session cookie, multi-tenant, cross-domain, tenant, transfer token, federated logout, OAuth redirect, central login, login session, attemptUser, sessionFields, cookie-backed session, resolveUser, token refresh, refresh token, access token, OIDC.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an expert on `ideal-auth`, the auth primitives library for the JS ecosystem. You have complete knowledge of its API, patterns, security model, and framework integrations. Use this knowledge to help users implement authentication correctly.
|
|
7
|
+
|
|
8
|
+
When the user asks to "set up auth" or "add authentication" in a project that has `ideal-auth` installed, use the AskUserQuestion tool to detect their setup:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
questions:
|
|
12
|
+
- question: "Which framework are you using?"
|
|
13
|
+
header: "Framework"
|
|
14
|
+
options:
|
|
15
|
+
- label: "Next.js (App Router)"
|
|
16
|
+
description: "React framework with Server Actions and middleware"
|
|
17
|
+
- label: "SvelteKit"
|
|
18
|
+
description: "Svelte framework with form actions and hooks"
|
|
19
|
+
- label: "Express"
|
|
20
|
+
description: "Node.js HTTP framework"
|
|
21
|
+
- label: "Hono"
|
|
22
|
+
description: "Lightweight framework for Node, Bun, Deno, Workers"
|
|
23
|
+
multiSelect: false
|
|
24
|
+
|
|
25
|
+
- question: "Which auth features do you need?"
|
|
26
|
+
header: "Features"
|
|
27
|
+
options:
|
|
28
|
+
- label: "Login + Registration"
|
|
29
|
+
description: "Email/password auth with session management"
|
|
30
|
+
- label: "Password Reset"
|
|
31
|
+
description: "Forgot password flow with email tokens"
|
|
32
|
+
- label: "Two-Factor Auth (TOTP)"
|
|
33
|
+
description: "Authenticator app + recovery codes"
|
|
34
|
+
- label: "Rate Limiting"
|
|
35
|
+
description: "Brute-force protection on login"
|
|
36
|
+
multiSelect: true
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Additional framework options to offer if the user picks "Other": Nuxt, TanStack Start, Elysia.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
# ideal-auth Complete Reference
|
|
44
|
+
|
|
45
|
+
## Overview
|
|
46
|
+
|
|
47
|
+
Auth primitives for the JS ecosystem. Zero framework dependencies. Inspired by Laravel's `Auth` and `Hash` facades.
|
|
48
|
+
|
|
49
|
+
- **Sessions**: Stateless, encrypted cookies via iron-session (AES-256-CBC + HMAC integrity)
|
|
50
|
+
- **Passwords**: bcrypt via bcryptjs with SHA-256 prehash for passwords > 72 bytes
|
|
51
|
+
- **Tokens**: HMAC-SHA256 signed, expiring tokens for password reset, email verification, magic links
|
|
52
|
+
- **TOTP**: RFC 6238 two-factor authentication with recovery codes
|
|
53
|
+
- **Rate Limiting**: Pluggable store (in-memory default, Redis/DB for production)
|
|
54
|
+
- **Crypto**: AES-256-GCM encryption, HMAC signing, timing-safe comparison
|
|
55
|
+
- **Cookie Bridge**: 3-function adapter — works with any framework
|
|
56
|
+
|
|
57
|
+
Install: `bun add ideal-auth` (or `npm install ideal-auth`)
|
|
58
|
+
|
|
59
|
+
Generate secret: `bunx ideal-auth secret` (outputs `IDEAL_AUTH_SECRET=...` for `.env`)
|
|
60
|
+
|
|
61
|
+
Generate encryption key: `bunx ideal-auth encryption-key` (for encrypting TOTP secrets at rest)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## API Reference
|
|
66
|
+
|
|
67
|
+
### `createAuth(config): () => AuthInstance`
|
|
68
|
+
|
|
69
|
+
Returns a factory function. Call `auth()` per request to get an `AuthInstance` scoped to that request's cookies. The instance caches the session payload and user — call it once per request and reuse.
|
|
70
|
+
|
|
71
|
+
#### AuthConfig
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
type AuthConfig<TUser extends AnyUser> = {
|
|
75
|
+
secret: string; // 32+ chars, required — throws if shorter
|
|
76
|
+
cookie: CookieBridge; // required
|
|
77
|
+
|
|
78
|
+
// Session mode — provide exactly ONE of these two:
|
|
79
|
+
resolveUser?: (id: string) => Promise<TUser | null>; // DB-backed: user() calls this every request
|
|
80
|
+
sessionFields?: (keyof TUser & string)[]; // Cookie-backed: user() reads from cookie, zero external calls
|
|
81
|
+
|
|
82
|
+
// Session options
|
|
83
|
+
session?: {
|
|
84
|
+
cookieName?: string; // default: 'ideal_session'
|
|
85
|
+
maxAge?: number; // default: 604800 (7 days, in seconds)
|
|
86
|
+
rememberMaxAge?: number; // default: 2592000 (30 days, in seconds)
|
|
87
|
+
cookie?: Partial<ConfigurableCookieOptions>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Laravel-style attempt (recommended)
|
|
91
|
+
hash?: HashInstance;
|
|
92
|
+
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
|
|
93
|
+
credentialKey?: string; // default: 'password'
|
|
94
|
+
passwordField?: string; // default: 'password'
|
|
95
|
+
|
|
96
|
+
// Manual attempt (escape hatch — takes precedence if both provided)
|
|
97
|
+
attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null>;
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### Session Modes
|
|
102
|
+
|
|
103
|
+
**Database-backed (`resolveUser`):** Cookie stores only user ID. `user()` calls `resolveUser(id)` every request. Best for real-time data freshness.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const auth = createAuth<User>({
|
|
107
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
108
|
+
cookie: createCookieBridge(),
|
|
109
|
+
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
|
|
110
|
+
hash,
|
|
111
|
+
resolveUserByCredentials: async (creds) =>
|
|
112
|
+
db.user.findUnique({ where: { email: creds.email } }),
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Cookie-backed (`sessionFields`):** Cookie stores user ID + declared fields. `user()` reads from cookie — zero external calls. Best for performance, stateless apps, or apps without a database.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const auth = createAuth<User>({
|
|
120
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
121
|
+
cookie: createCookieBridge(),
|
|
122
|
+
sessionFields: ['email', 'name', 'role'],
|
|
123
|
+
hash,
|
|
124
|
+
resolveUserByCredentials: async (creds) =>
|
|
125
|
+
db.user.findUnique({ where: { email: creds.email } }),
|
|
126
|
+
});
|
|
127
|
+
// user() returns { id, email, name, role } from cookie
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Key rules:
|
|
131
|
+
- Provide exactly one of `resolveUser` or `sessionFields` — both or neither throws
|
|
132
|
+
- `id` is always stored — don't include it in `sessionFields`
|
|
133
|
+
- Cookie limit is ~4KB — store basic fields only
|
|
134
|
+
- `sessionFields` data is a snapshot from login time — stale if user updates profile mid-session
|
|
135
|
+
- To refresh, call `auth().login(updatedUser)` after the update
|
|
136
|
+
- `loginById()` requires `resolveUser` — throws with `sessionFields`
|
|
137
|
+
- Both modes work with `attempt()`, `attemptUser`, `hash + resolveUserByCredentials`
|
|
138
|
+
|
|
139
|
+
#### AuthInstance Methods
|
|
140
|
+
|
|
141
|
+
| Method | Returns | Description |
|
|
142
|
+
|--------|---------|-------------|
|
|
143
|
+
| `login(user, options?)` | `Promise<void>` | Set session cookie for the given user |
|
|
144
|
+
| `loginById(id, options?)` | `Promise<void>` | Resolve user by ID, then set session cookie (requires `resolveUser`) |
|
|
145
|
+
| `attempt(credentials, options?)` | `Promise<boolean>` | Find user, verify password, login if valid |
|
|
146
|
+
| `logout()` | `Promise<void>` | Delete session cookie |
|
|
147
|
+
| `check()` | `Promise<boolean>` | Is the session valid? (fast, cached) |
|
|
148
|
+
| `user()` | `Promise<TUser \| null>` | Get the authenticated user (from DB with `resolveUser`, or from cookie with `sessionFields`) |
|
|
149
|
+
| `id()` | `Promise<string \| null>` | Get the authenticated user's ID |
|
|
150
|
+
|
|
151
|
+
#### LoginOptions
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
type LoginOptions = {
|
|
155
|
+
remember?: boolean;
|
|
156
|
+
// true: use rememberMaxAge (30 days)
|
|
157
|
+
// false: session cookie (expires when browser closes)
|
|
158
|
+
// undefined: use default maxAge (7 days)
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### `attempt()` — Two Modes
|
|
163
|
+
|
|
164
|
+
**Laravel-style (recommended):** Provide `hash` and `resolveUserByCredentials`. The `attempt()` method strips the credential key (default `'password'`) from credentials, looks up the user with remaining fields, and verifies the hash automatically.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const auth = createAuth({
|
|
168
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
169
|
+
cookie: createCookieBridge(),
|
|
170
|
+
hash,
|
|
171
|
+
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
|
|
172
|
+
resolveUserByCredentials: async (creds) => {
|
|
173
|
+
// creds = { email: '...' } — password already stripped
|
|
174
|
+
return db.user.findUnique({ where: { email: creds.email } });
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Manual (escape hatch):** Provide `attemptUser` for full control over lookup and verification. Takes precedence if both are provided.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
const auth = createAuth({
|
|
183
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
184
|
+
cookie: createCookieBridge(),
|
|
185
|
+
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
|
|
186
|
+
attemptUser: async (creds) => {
|
|
187
|
+
const user = await db.user.findUnique({ where: { email: creds.email } });
|
|
188
|
+
if (!user) return null;
|
|
189
|
+
if (!(await hash.verify(creds.password, user.password))) return null;
|
|
190
|
+
return user;
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### `createHash(config?): HashInstance`
|
|
198
|
+
|
|
199
|
+
bcrypt password hashing with automatic SHA-256 prehash for passwords > 72 bytes.
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
type HashConfig = { rounds?: number }; // default: 12
|
|
203
|
+
|
|
204
|
+
type HashInstance = {
|
|
205
|
+
make(password: string): Promise<string>;
|
|
206
|
+
verify(password: string, hash: string): Promise<boolean>;
|
|
207
|
+
};
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { createHash } from 'ideal-auth';
|
|
212
|
+
|
|
213
|
+
const hash = createHash({ rounds: 12 });
|
|
214
|
+
const hashed = await hash.make('password');
|
|
215
|
+
const valid = await hash.verify('password', hashed); // true
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
### `createTokenVerifier(config): TokenVerifierInstance`
|
|
221
|
+
|
|
222
|
+
Signed, expiring tokens for password resets, email verification, magic links, invites. Create one instance per use case with its own secret/expiry.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
type TokenVerifierConfig = {
|
|
226
|
+
secret: string; // 32+ chars, required
|
|
227
|
+
expiryMs?: number; // default: 3600000 (1 hour)
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
type TokenVerifierInstance = {
|
|
231
|
+
createToken(userId: string): string;
|
|
232
|
+
verifyToken(token: string): { userId: string; iatMs: number } | null;
|
|
233
|
+
};
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Token format: `encodedUserId.randomId.issuedAtMs.expiryMs.signature` (HMAC-SHA256 signed).
|
|
237
|
+
|
|
238
|
+
**Important:** Tokens are stateless. Use `iatMs` to reject tokens issued before a relevant event (e.g., password change). Use different secrets per use case so tokens aren't interchangeable.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
### `createTOTP(config?): TOTPInstance`
|
|
243
|
+
|
|
244
|
+
RFC 6238 TOTP generation and verification.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
type TOTPConfig = {
|
|
248
|
+
digits?: number; // default: 6
|
|
249
|
+
period?: number; // default: 30 (seconds)
|
|
250
|
+
window?: number; // default: 1 (±1 time step, ~90 second acceptance window)
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
type TOTPInstance = {
|
|
254
|
+
generateSecret(): string; // 32-char base32 string
|
|
255
|
+
generateQrUri(opts: { secret: string; issuer: string; account: string }): string;
|
|
256
|
+
verify(token: string, secret: string): boolean;
|
|
257
|
+
};
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
### `generateRecoveryCodes(hash, count?): Promise<{ codes, hashed }>`
|
|
263
|
+
|
|
264
|
+
Generate backup codes for 2FA recovery. Returns plaintext codes (show once to user) and bcrypt-hashed codes (store in DB).
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { generateRecoveryCodes, verifyRecoveryCode, createHash } from 'ideal-auth';
|
|
268
|
+
|
|
269
|
+
const hash = createHash();
|
|
270
|
+
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
|
|
271
|
+
// codes: string[] — show to user once (format: XXXXXXXX-XXXXXXXX)
|
|
272
|
+
// hashed: string[] — store in database
|
|
273
|
+
|
|
274
|
+
const { valid, remaining } = await verifyRecoveryCode(code, storedHashes, hash);
|
|
275
|
+
// valid: boolean
|
|
276
|
+
// remaining: string[] — update DB with this (removes used code)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
### `createRateLimiter(config): RateLimiterInstance`
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
type RateLimiterConfig = {
|
|
285
|
+
maxAttempts: number; // required
|
|
286
|
+
windowMs: number; // required
|
|
287
|
+
store?: RateLimitStore; // default: MemoryRateLimitStore
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
type RateLimitResult = {
|
|
291
|
+
allowed: boolean;
|
|
292
|
+
remaining: number;
|
|
293
|
+
resetAt: Date;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Methods: attempt(key): Promise<RateLimitResult>, reset(key): Promise<void>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**MemoryRateLimitStore** characteristics: max 10,000 entries, 1-minute cleanup interval, resets on process restart, single-process only. **Use a persistent store (Redis/DB) in production.**
|
|
300
|
+
|
|
301
|
+
Custom store interface:
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
interface RateLimitStore {
|
|
305
|
+
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
|
|
306
|
+
reset(key: string): Promise<void>;
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
### Crypto Utilities
|
|
313
|
+
|
|
314
|
+
All use `node:crypto` — no third-party dependencies.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import {
|
|
318
|
+
generateToken,
|
|
319
|
+
signData,
|
|
320
|
+
verifySignature,
|
|
321
|
+
encrypt,
|
|
322
|
+
decrypt,
|
|
323
|
+
timingSafeEqual,
|
|
324
|
+
} from 'ideal-auth';
|
|
325
|
+
|
|
326
|
+
// Random hex token (default 32 bytes = 64 hex chars)
|
|
327
|
+
const token = generateToken();
|
|
328
|
+
const short = generateToken(16); // 32 hex chars
|
|
329
|
+
|
|
330
|
+
// HMAC-SHA256 signing
|
|
331
|
+
const sig = signData('user:123:reset', secret);
|
|
332
|
+
const valid = verifySignature('user:123:reset', sig, secret);
|
|
333
|
+
|
|
334
|
+
// AES-256-GCM encryption (scrypt key derivation, base64url output)
|
|
335
|
+
const encrypted = await encrypt('sensitive data', secret);
|
|
336
|
+
const decrypted = await decrypt(encrypted, secret);
|
|
337
|
+
|
|
338
|
+
// Constant-time string comparison
|
|
339
|
+
timingSafeEqual('abc', 'abc'); // true
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
### Key Types
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
type AnyUser = { id: string | number; [key: string]: any };
|
|
348
|
+
|
|
349
|
+
type CookieBridge = {
|
|
350
|
+
get(name: string): Promise<string | undefined> | string | undefined;
|
|
351
|
+
set(name: string, value: string, options: CookieOptions): Promise<void> | void;
|
|
352
|
+
delete(name: string): Promise<void> | void;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
type CookieOptions = {
|
|
356
|
+
httpOnly?: boolean;
|
|
357
|
+
secure?: boolean;
|
|
358
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
359
|
+
path?: string;
|
|
360
|
+
maxAge?: number;
|
|
361
|
+
expires?: Date;
|
|
362
|
+
domain?: string;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// httpOnly is always forced to true — not configurable
|
|
366
|
+
type ConfigurableCookieOptions = Omit<CookieOptions, 'httpOnly'>;
|
|
367
|
+
|
|
368
|
+
type SessionPayload = {
|
|
369
|
+
uid: string; // user ID (always string)
|
|
370
|
+
iat: number; // issued-at (Unix seconds)
|
|
371
|
+
exp: number; // expiration (Unix seconds)
|
|
372
|
+
};
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## Cookie Bridge Patterns
|
|
378
|
+
|
|
379
|
+
### Next.js (App Router)
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// lib/cookies.ts
|
|
383
|
+
import { cookies } from 'next/headers';
|
|
384
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
385
|
+
|
|
386
|
+
export function createCookieBridge(): CookieBridge {
|
|
387
|
+
return {
|
|
388
|
+
async get(name: string) {
|
|
389
|
+
const cookieStore = await cookies();
|
|
390
|
+
return cookieStore.get(name)?.value;
|
|
391
|
+
},
|
|
392
|
+
async set(name, value, options) {
|
|
393
|
+
const cookieStore = await cookies();
|
|
394
|
+
cookieStore.set(name, value, options);
|
|
395
|
+
},
|
|
396
|
+
async delete(name) {
|
|
397
|
+
const cookieStore = await cookies();
|
|
398
|
+
cookieStore.delete(name);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**IMPORTANT:** `cookies()` is async in Next.js 15+. If on Next.js 14, remove the `await`.
|
|
405
|
+
|
|
406
|
+
Auth setup:
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
// lib/auth.ts
|
|
410
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
411
|
+
import { createCookieBridge } from './cookies';
|
|
412
|
+
import { db } from './db';
|
|
413
|
+
|
|
414
|
+
type User = {
|
|
415
|
+
id: string;
|
|
416
|
+
email: string;
|
|
417
|
+
name: string;
|
|
418
|
+
password: string;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const hash = createHash({ rounds: 12 });
|
|
422
|
+
|
|
423
|
+
const auth = createAuth<User>({
|
|
424
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
425
|
+
cookie: createCookieBridge(),
|
|
426
|
+
hash,
|
|
427
|
+
|
|
428
|
+
async resolveUser(id) {
|
|
429
|
+
return db.user.findUnique({ where: { id } });
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async resolveUserByCredentials(credentials) {
|
|
433
|
+
return db.user.findUnique({
|
|
434
|
+
where: { email: credentials.email },
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
export { auth, hash };
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**`createAuth` returns a factory function.** Call `auth()` inside each Server Action. Do not call at module level.
|
|
443
|
+
|
|
444
|
+
Login action:
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
// app/actions/login.ts
|
|
448
|
+
'use server';
|
|
449
|
+
|
|
450
|
+
import { redirect } from 'next/navigation';
|
|
451
|
+
import { auth } from '@/lib/auth';
|
|
452
|
+
|
|
453
|
+
export async function loginAction(_prev: unknown, formData: FormData) {
|
|
454
|
+
const email = formData.get('email') as string;
|
|
455
|
+
const password = formData.get('password') as string;
|
|
456
|
+
const remember = formData.get('remember') === 'on';
|
|
457
|
+
|
|
458
|
+
if (!email || !password) {
|
|
459
|
+
return { error: 'Email and password are required.' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const session = auth();
|
|
463
|
+
const success = await session.attempt(
|
|
464
|
+
{ email, password },
|
|
465
|
+
{ remember },
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
if (!success) {
|
|
469
|
+
return { error: 'Invalid email or password.' };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
redirect('/dashboard');
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Login form:
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
// app/login/page.tsx
|
|
480
|
+
'use client';
|
|
481
|
+
|
|
482
|
+
import { useActionState } from 'react';
|
|
483
|
+
import { loginAction } from '@/app/actions/login';
|
|
484
|
+
|
|
485
|
+
export default function LoginPage() {
|
|
486
|
+
const [state, formAction, pending] = useActionState(loginAction, null);
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<form action={formAction}>
|
|
490
|
+
{state?.error && <p className="text-red-500">{state.error}</p>}
|
|
491
|
+
|
|
492
|
+
<label htmlFor="email">Email</label>
|
|
493
|
+
<input id="email" name="email" type="email" required />
|
|
494
|
+
|
|
495
|
+
<label htmlFor="password">Password</label>
|
|
496
|
+
<input id="password" name="password" type="password" required />
|
|
497
|
+
|
|
498
|
+
<label>
|
|
499
|
+
<input name="remember" type="checkbox" /> Remember me
|
|
500
|
+
</label>
|
|
501
|
+
|
|
502
|
+
<button type="submit" disabled={pending}>
|
|
503
|
+
{pending ? 'Signing in...' : 'Sign in'}
|
|
504
|
+
</button>
|
|
505
|
+
</form>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Registration action:
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
// app/actions/register.ts
|
|
514
|
+
'use server';
|
|
515
|
+
|
|
516
|
+
import { redirect } from 'next/navigation';
|
|
517
|
+
import { auth, hash } from '@/lib/auth';
|
|
518
|
+
import { db } from '@/lib/db';
|
|
519
|
+
|
|
520
|
+
export async function registerAction(_prev: unknown, formData: FormData) {
|
|
521
|
+
const email = formData.get('email') as string;
|
|
522
|
+
const name = formData.get('name') as string;
|
|
523
|
+
const password = formData.get('password') as string;
|
|
524
|
+
const passwordConfirmation = formData.get('password_confirmation') as string;
|
|
525
|
+
|
|
526
|
+
if (!email || !name || !password) {
|
|
527
|
+
return { error: 'All fields are required.' };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (password.length < 8) {
|
|
531
|
+
return { error: 'Password must be at least 8 characters.' };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (password !== passwordConfirmation) {
|
|
535
|
+
return { error: 'Passwords do not match.' };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const existing = await db.user.findUnique({ where: { email } });
|
|
539
|
+
if (existing) {
|
|
540
|
+
return { error: 'An account with this email already exists.' };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const user = await db.user.create({
|
|
544
|
+
data: {
|
|
545
|
+
email,
|
|
546
|
+
name,
|
|
547
|
+
password: await hash.make(password),
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const session = auth();
|
|
552
|
+
await session.login(user);
|
|
553
|
+
|
|
554
|
+
redirect('/dashboard');
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Logout action:
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
// app/actions/logout.ts
|
|
562
|
+
'use server';
|
|
563
|
+
|
|
564
|
+
import { redirect } from 'next/navigation';
|
|
565
|
+
import { auth } from '@/lib/auth';
|
|
566
|
+
|
|
567
|
+
export async function logoutAction() {
|
|
568
|
+
const session = auth();
|
|
569
|
+
await session.logout();
|
|
570
|
+
redirect('/login');
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Middleware (route protection):
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
// middleware.ts
|
|
578
|
+
import { NextResponse } from 'next/server';
|
|
579
|
+
import type { NextRequest } from 'next/server';
|
|
580
|
+
|
|
581
|
+
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
|
|
582
|
+
const authRoutes = ['/login', '/register'];
|
|
583
|
+
|
|
584
|
+
export function middleware(request: NextRequest) {
|
|
585
|
+
const { pathname } = request.nextUrl;
|
|
586
|
+
const hasSession = request.cookies.has('ideal_session');
|
|
587
|
+
|
|
588
|
+
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
|
|
589
|
+
if (!hasSession) {
|
|
590
|
+
const loginUrl = new URL('/login', request.url);
|
|
591
|
+
loginUrl.searchParams.set('callbackUrl', pathname);
|
|
592
|
+
return NextResponse.redirect(loginUrl);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (authRoutes.some((route) => pathname.startsWith(route))) {
|
|
597
|
+
if (hasSession) {
|
|
598
|
+
return NextResponse.redirect(new URL('/dashboard', request.url));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return NextResponse.next();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export const config = {
|
|
606
|
+
matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/login', '/register'],
|
|
607
|
+
};
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
**Note:** Next.js middleware runs on Edge Runtime. `auth()` requires Node.js runtime (iron-session uses Node crypto). The middleware checks cookie existence as a fast first pass; actual cryptographic verification happens server-side via `auth().check()` or `auth().user()`.
|
|
611
|
+
|
|
612
|
+
Server-side auth guard helper:
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
// lib/auth-guard.ts
|
|
616
|
+
import { redirect } from 'next/navigation';
|
|
617
|
+
import { auth } from '@/lib/auth';
|
|
618
|
+
|
|
619
|
+
export async function requireAuth() {
|
|
620
|
+
const session = auth();
|
|
621
|
+
const user = await session.user();
|
|
622
|
+
|
|
623
|
+
if (!user) {
|
|
624
|
+
redirect('/login');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return user;
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Getting the current user in a Server Component:
|
|
632
|
+
|
|
633
|
+
```tsx
|
|
634
|
+
// app/dashboard/page.tsx
|
|
635
|
+
import { auth } from '@/lib/auth';
|
|
636
|
+
import { redirect } from 'next/navigation';
|
|
637
|
+
|
|
638
|
+
export default async function DashboardPage() {
|
|
639
|
+
const session = auth();
|
|
640
|
+
const user = await session.user();
|
|
641
|
+
|
|
642
|
+
if (!user) redirect('/login');
|
|
643
|
+
|
|
644
|
+
return <h1>Welcome, {user.name}</h1>;
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Pass user data to Client Components as props — only pass serializable, non-sensitive fields.
|
|
649
|
+
|
|
650
|
+
**CSRF:** Next.js Server Actions have built-in CSRF protection (Origin header validation). For API Route Handlers, validate the Origin header manually:
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
// app/api/example/route.ts
|
|
654
|
+
import { headers } from 'next/headers';
|
|
655
|
+
import { NextResponse } from 'next/server';
|
|
656
|
+
|
|
657
|
+
export async function POST(request: Request) {
|
|
658
|
+
const headerStore = await headers();
|
|
659
|
+
const origin = headerStore.get('origin');
|
|
660
|
+
const host = headerStore.get('host');
|
|
661
|
+
|
|
662
|
+
if (!origin || new URL(origin).host !== host) {
|
|
663
|
+
return NextResponse.json({ error: 'Invalid origin' }, { status: 403 });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ... handle request
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
**Edge Runtime:** Use `export const runtime = 'nodejs'` in any Route Handler that calls `auth()`.
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
### SvelteKit
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// src/lib/server/cookies.ts
|
|
678
|
+
import type { Cookies } from '@sveltejs/kit';
|
|
679
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
680
|
+
|
|
681
|
+
export function createCookieBridge(cookies: Cookies): CookieBridge {
|
|
682
|
+
return {
|
|
683
|
+
get(name: string) {
|
|
684
|
+
return cookies.get(name);
|
|
685
|
+
},
|
|
686
|
+
set(name, value, options) {
|
|
687
|
+
cookies.set(name, value, {
|
|
688
|
+
...options,
|
|
689
|
+
path: options.path ?? '/',
|
|
690
|
+
});
|
|
691
|
+
},
|
|
692
|
+
delete(name) {
|
|
693
|
+
cookies.delete(name, { path: '/' });
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
SvelteKit requires an explicit `path` on `cookies.set()`.
|
|
700
|
+
|
|
701
|
+
Auth setup:
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
// src/lib/server/auth.ts
|
|
705
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
706
|
+
import { createCookieBridge } from './cookies';
|
|
707
|
+
import { db } from '$lib/server/db';
|
|
708
|
+
import { IDEAL_AUTH_SECRET } from '$env/static/private';
|
|
709
|
+
|
|
710
|
+
type User = { id: string; email: string; name: string; password: string };
|
|
711
|
+
|
|
712
|
+
export const hash = createHash({ rounds: 12 });
|
|
713
|
+
|
|
714
|
+
export function auth(cookies: import('@sveltejs/kit').Cookies) {
|
|
715
|
+
const authFactory = createAuth<User>({
|
|
716
|
+
secret: IDEAL_AUTH_SECRET,
|
|
717
|
+
cookie: createCookieBridge(cookies),
|
|
718
|
+
hash,
|
|
719
|
+
|
|
720
|
+
async resolveUser(id) {
|
|
721
|
+
return db.user.findUnique({ where: { id } });
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
async resolveUserByCredentials(credentials) {
|
|
725
|
+
return db.user.findUnique({ where: { email: credentials.email } });
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
return authFactory();
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Pass `cookies` from the `RequestEvent` each time — keeps requests isolated.
|
|
734
|
+
|
|
735
|
+
Login (form action):
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// src/routes/login/+page.server.ts
|
|
739
|
+
import { fail, redirect } from '@sveltejs/kit';
|
|
740
|
+
import { auth } from '$lib/server/auth';
|
|
741
|
+
import type { Actions, PageServerLoad } from './$types';
|
|
742
|
+
|
|
743
|
+
export const load: PageServerLoad = async ({ cookies }) => {
|
|
744
|
+
const session = auth(cookies);
|
|
745
|
+
if (await session.check()) redirect(303, '/dashboard');
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
export const actions: Actions = {
|
|
749
|
+
default: async ({ request, cookies }) => {
|
|
750
|
+
const data = await request.formData();
|
|
751
|
+
const email = data.get('email') as string;
|
|
752
|
+
const password = data.get('password') as string;
|
|
753
|
+
const remember = data.get('remember') === 'on';
|
|
754
|
+
|
|
755
|
+
if (!email || !password) {
|
|
756
|
+
return fail(400, { error: 'Email and password are required.', email });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const session = auth(cookies);
|
|
760
|
+
const success = await session.attempt({ email, password }, { remember });
|
|
761
|
+
|
|
762
|
+
if (!success) {
|
|
763
|
+
return fail(400, { error: 'Invalid email or password.', email });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
redirect(303, '/dashboard');
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Registration (form action):
|
|
772
|
+
|
|
773
|
+
```typescript
|
|
774
|
+
// src/routes/register/+page.server.ts
|
|
775
|
+
import { fail, redirect } from '@sveltejs/kit';
|
|
776
|
+
import { auth, hash } from '$lib/server/auth';
|
|
777
|
+
import { db } from '$lib/server/db';
|
|
778
|
+
import type { Actions } from './$types';
|
|
779
|
+
|
|
780
|
+
export const actions: Actions = {
|
|
781
|
+
default: async ({ request, cookies }) => {
|
|
782
|
+
const data = await request.formData();
|
|
783
|
+
const email = data.get('email') as string;
|
|
784
|
+
const name = data.get('name') as string;
|
|
785
|
+
const password = data.get('password') as string;
|
|
786
|
+
const passwordConfirmation = data.get('password_confirmation') as string;
|
|
787
|
+
|
|
788
|
+
if (!email || !name || !password) {
|
|
789
|
+
return fail(400, { error: 'All fields are required.', email, name });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (password.length < 8) {
|
|
793
|
+
return fail(400, { error: 'Password must be at least 8 characters.', email, name });
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (password !== passwordConfirmation) {
|
|
797
|
+
return fail(400, { error: 'Passwords do not match.', email, name });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const existing = await db.user.findUnique({ where: { email } });
|
|
801
|
+
if (existing) {
|
|
802
|
+
return fail(400, { error: 'An account with this email already exists.', email, name });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const user = await db.user.create({
|
|
806
|
+
data: { email, name, password: await hash.make(password) },
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const session = auth(cookies);
|
|
810
|
+
await session.login(user);
|
|
811
|
+
|
|
812
|
+
redirect(303, '/dashboard');
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Auth guard (handle hook):
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
// src/hooks.server.ts
|
|
821
|
+
import { redirect, type Handle } from '@sveltejs/kit';
|
|
822
|
+
import { auth } from '$lib/server/auth';
|
|
823
|
+
|
|
824
|
+
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
|
|
825
|
+
const authRoutes = ['/login', '/register'];
|
|
826
|
+
|
|
827
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
828
|
+
const session = auth(event.cookies);
|
|
829
|
+
const user = await session.user();
|
|
830
|
+
|
|
831
|
+
event.locals.user = user
|
|
832
|
+
? { id: user.id, email: user.email, name: user.name }
|
|
833
|
+
: null;
|
|
834
|
+
|
|
835
|
+
const { pathname } = event.url;
|
|
836
|
+
|
|
837
|
+
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
|
|
838
|
+
if (!user) redirect(303, `/login?callbackUrl=${encodeURIComponent(pathname)}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (authRoutes.some((route) => pathname.startsWith(route))) {
|
|
842
|
+
if (user) redirect(303, '/dashboard');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return resolve(event);
|
|
846
|
+
};
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
Declare types in `src/app.d.ts`:
|
|
850
|
+
|
|
851
|
+
```typescript
|
|
852
|
+
declare global {
|
|
853
|
+
namespace App {
|
|
854
|
+
interface Locals {
|
|
855
|
+
user: { id: string; email: string; name: string } | null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export {};
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
**CSRF:** SvelteKit has built-in CSRF protection — auto-validates Origin header on all form submissions. Do not set `checkOrigin: false` in production.
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
### Express
|
|
868
|
+
|
|
869
|
+
Requires `cookie-parser`: `bun add cookie-parser` + `bun add -D @types/cookie-parser @types/express`
|
|
870
|
+
|
|
871
|
+
```typescript
|
|
872
|
+
// src/lib/cookies.ts
|
|
873
|
+
import type { Request, Response } from 'express';
|
|
874
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
875
|
+
|
|
876
|
+
export function createCookieBridge(req: Request, res: Response): CookieBridge {
|
|
877
|
+
return {
|
|
878
|
+
get(name: string) {
|
|
879
|
+
return req.cookies[name];
|
|
880
|
+
},
|
|
881
|
+
set(name, value, options) {
|
|
882
|
+
res.cookie(name, value, {
|
|
883
|
+
httpOnly: options.httpOnly,
|
|
884
|
+
secure: options.secure,
|
|
885
|
+
sameSite: options.sameSite,
|
|
886
|
+
path: options.path ?? '/',
|
|
887
|
+
...(options.maxAge !== undefined && { maxAge: options.maxAge * 1000 }),
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
delete(name) {
|
|
891
|
+
res.clearCookie(name, { path: '/' });
|
|
892
|
+
},
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
**IMPORTANT:** Express `res.cookie()` uses milliseconds for `maxAge`, but ideal-auth provides seconds. The bridge multiplies by 1000.
|
|
898
|
+
|
|
899
|
+
Auth setup:
|
|
900
|
+
|
|
901
|
+
```typescript
|
|
902
|
+
// src/lib/auth.ts
|
|
903
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
904
|
+
import { createCookieBridge } from './cookies';
|
|
905
|
+
import { db } from './db';
|
|
906
|
+
|
|
907
|
+
type User = { id: string; email: string; name: string; password: string };
|
|
908
|
+
|
|
909
|
+
export const hash = createHash({ rounds: 12 });
|
|
910
|
+
|
|
911
|
+
export function auth(req: import('express').Request, res: import('express').Response) {
|
|
912
|
+
const authFactory = createAuth<User>({
|
|
913
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
914
|
+
cookie: createCookieBridge(req, res),
|
|
915
|
+
hash,
|
|
916
|
+
|
|
917
|
+
async resolveUser(id) {
|
|
918
|
+
return db.user.findUnique({ where: { id } });
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
async resolveUserByCredentials(credentials) {
|
|
922
|
+
return db.user.findUnique({ where: { email: credentials.email } });
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
return authFactory();
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
App setup:
|
|
931
|
+
|
|
932
|
+
```typescript
|
|
933
|
+
// src/app.ts
|
|
934
|
+
import express from 'express';
|
|
935
|
+
import cookieParser from 'cookie-parser';
|
|
936
|
+
|
|
937
|
+
const app = express();
|
|
938
|
+
app.use(express.json());
|
|
939
|
+
app.use(express.urlencoded({ extended: true }));
|
|
940
|
+
app.use(cookieParser());
|
|
941
|
+
app.use(csrfProtection); // see CSRF section below
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
Auth middleware:
|
|
945
|
+
|
|
946
|
+
```typescript
|
|
947
|
+
// src/middleware/auth.ts
|
|
948
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
949
|
+
import { auth } from '../lib/auth';
|
|
950
|
+
|
|
951
|
+
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
952
|
+
const session = auth(req, res);
|
|
953
|
+
const user = await session.user();
|
|
954
|
+
|
|
955
|
+
if (!user) {
|
|
956
|
+
return res.status(401).json({ error: 'Authentication required.' });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
req.user = user;
|
|
960
|
+
next();
|
|
961
|
+
}
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
Extend Express Request type:
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
// src/types/express.d.ts
|
|
968
|
+
declare global {
|
|
969
|
+
namespace Express {
|
|
970
|
+
interface Request {
|
|
971
|
+
user?: { id: string; email: string; name: string };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export {};
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
**CSRF:** Express has NO built-in CSRF protection. Implement Origin header validation:
|
|
980
|
+
|
|
981
|
+
```typescript
|
|
982
|
+
// src/middleware/csrf.ts
|
|
983
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
984
|
+
|
|
985
|
+
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
986
|
+
|
|
987
|
+
export function csrfProtection(req: Request, res: Response, next: NextFunction) {
|
|
988
|
+
if (SAFE_METHODS.includes(req.method)) return next();
|
|
989
|
+
|
|
990
|
+
const origin = req.get('origin');
|
|
991
|
+
const host = req.get('host');
|
|
992
|
+
|
|
993
|
+
if (!origin || !host) {
|
|
994
|
+
return res.status(403).json({ error: 'Forbidden: missing origin header.' });
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
try {
|
|
998
|
+
if (new URL(origin).host !== host) {
|
|
999
|
+
return res.status(403).json({ error: 'Forbidden: origin mismatch.' });
|
|
1000
|
+
}
|
|
1001
|
+
} catch {
|
|
1002
|
+
return res.status(403).json({ error: 'Forbidden: invalid origin.' });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
next();
|
|
1006
|
+
}
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
For HTML forms, use a CSRF token approach with `generateToken` and `timingSafeEqual` from ideal-auth.
|
|
1010
|
+
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
### Hono
|
|
1014
|
+
|
|
1015
|
+
```typescript
|
|
1016
|
+
// src/lib/cookies.ts
|
|
1017
|
+
import type { Context } from 'hono';
|
|
1018
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
|
1019
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
1020
|
+
|
|
1021
|
+
export function createCookieBridge(c: Context): CookieBridge {
|
|
1022
|
+
return {
|
|
1023
|
+
get(name: string) {
|
|
1024
|
+
return getCookie(c, name);
|
|
1025
|
+
},
|
|
1026
|
+
set(name, value, options) {
|
|
1027
|
+
setCookie(c, name, value, {
|
|
1028
|
+
httpOnly: options.httpOnly,
|
|
1029
|
+
secure: options.secure,
|
|
1030
|
+
sameSite: options.sameSite === 'lax' ? 'Lax' : options.sameSite === 'strict' ? 'Strict' : 'None',
|
|
1031
|
+
path: options.path ?? '/',
|
|
1032
|
+
...(options.maxAge !== undefined && { maxAge: options.maxAge }),
|
|
1033
|
+
});
|
|
1034
|
+
},
|
|
1035
|
+
delete(name) {
|
|
1036
|
+
deleteCookie(c, name, { path: '/' });
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
**Note:** Hono expects capitalized `sameSite` values (`'Lax'`, `'Strict'`, `'None'`). The bridge converts.
|
|
1043
|
+
|
|
1044
|
+
Auth setup:
|
|
1045
|
+
|
|
1046
|
+
```typescript
|
|
1047
|
+
// src/lib/auth.ts
|
|
1048
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
1049
|
+
import { createCookieBridge } from './cookies';
|
|
1050
|
+
import { db } from './db';
|
|
1051
|
+
|
|
1052
|
+
type User = { id: string; email: string; name: string; password: string };
|
|
1053
|
+
|
|
1054
|
+
export const hash = createHash({ rounds: 12 });
|
|
1055
|
+
|
|
1056
|
+
export function auth(c: import('hono').Context) {
|
|
1057
|
+
const authFactory = createAuth<User>({
|
|
1058
|
+
secret: c.env?.IDEAL_AUTH_SECRET ?? process.env.IDEAL_AUTH_SECRET!,
|
|
1059
|
+
cookie: createCookieBridge(c),
|
|
1060
|
+
hash,
|
|
1061
|
+
|
|
1062
|
+
async resolveUser(id) {
|
|
1063
|
+
return db.user.findUnique({ where: { id } });
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
async resolveUserByCredentials(credentials) {
|
|
1067
|
+
return db.user.findUnique({ where: { email: credentials.email } });
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
return authFactory();
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
Auth middleware:
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
// src/middleware/auth.ts
|
|
1079
|
+
import { createMiddleware } from 'hono/factory';
|
|
1080
|
+
import { auth } from '../lib/auth';
|
|
1081
|
+
|
|
1082
|
+
type Env = {
|
|
1083
|
+
Variables: { user: { id: string; email: string; name: string } };
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
|
1087
|
+
const session = auth(c);
|
|
1088
|
+
const user = await session.user();
|
|
1089
|
+
|
|
1090
|
+
if (!user) return c.json({ error: 'Authentication required.' }, 401);
|
|
1091
|
+
|
|
1092
|
+
c.set('user', { id: user.id, email: user.email, name: user.name });
|
|
1093
|
+
await next();
|
|
1094
|
+
});
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
**CSRF:** Hono has built-in `csrf()` middleware:
|
|
1098
|
+
|
|
1099
|
+
```typescript
|
|
1100
|
+
import { csrf } from 'hono/csrf';
|
|
1101
|
+
app.use(csrf());
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
**Cloudflare Workers:** Add `nodejs_compat` compatibility flag to `wrangler.toml`. Access env via `c.env.IDEAL_AUTH_SECRET`.
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
### Nuxt
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
// server/utils/cookies.ts
|
|
1112
|
+
import type { H3Event } from 'h3';
|
|
1113
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
1114
|
+
|
|
1115
|
+
export function createCookieBridge(event: H3Event): CookieBridge {
|
|
1116
|
+
return {
|
|
1117
|
+
get(name: string) {
|
|
1118
|
+
return getCookie(event, name);
|
|
1119
|
+
},
|
|
1120
|
+
set(name, value, options) {
|
|
1121
|
+
setCookie(event, name, value, options);
|
|
1122
|
+
},
|
|
1123
|
+
delete(name) {
|
|
1124
|
+
deleteCookie(event, name, { path: '/' });
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
`getCookie`, `setCookie`, `deleteCookie` are auto-imported from `h3` in Nuxt server routes.
|
|
1131
|
+
|
|
1132
|
+
Auth setup:
|
|
1133
|
+
|
|
1134
|
+
```typescript
|
|
1135
|
+
// server/utils/auth.ts
|
|
1136
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
1137
|
+
import { createCookieBridge } from './cookies';
|
|
1138
|
+
|
|
1139
|
+
type User = { id: string; email: string; name: string; password: string };
|
|
1140
|
+
|
|
1141
|
+
export const hash = createHash({ rounds: 12 });
|
|
1142
|
+
|
|
1143
|
+
export function auth(event: H3Event) {
|
|
1144
|
+
const config = useRuntimeConfig();
|
|
1145
|
+
|
|
1146
|
+
const authFactory = createAuth<User>({
|
|
1147
|
+
secret: config.idealAuthSecret,
|
|
1148
|
+
cookie: createCookieBridge(event),
|
|
1149
|
+
hash,
|
|
1150
|
+
|
|
1151
|
+
async resolveUser(id) {
|
|
1152
|
+
return db.user.findUnique({ where: { id } });
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
async resolveUserByCredentials(credentials) {
|
|
1156
|
+
return db.user.findUnique({ where: { email: credentials.email } });
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
return authFactory();
|
|
1161
|
+
}
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
Register the secret in `nuxt.config.ts`:
|
|
1165
|
+
|
|
1166
|
+
```typescript
|
|
1167
|
+
export default defineNuxtConfig({
|
|
1168
|
+
runtimeConfig: {
|
|
1169
|
+
idealAuthSecret: process.env.IDEAL_AUTH_SECRET,
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
Files in `server/utils/` are auto-imported. Call `auth(event)` directly.
|
|
1175
|
+
|
|
1176
|
+
Login API route:
|
|
1177
|
+
|
|
1178
|
+
```typescript
|
|
1179
|
+
// server/api/auth/login.post.ts
|
|
1180
|
+
export default defineEventHandler(async (event) => {
|
|
1181
|
+
const body = await readBody(event);
|
|
1182
|
+
|
|
1183
|
+
if (!body.email || !body.password) {
|
|
1184
|
+
throw createError({ statusCode: 400, statusMessage: 'Email and password are required.' });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const session = auth(event);
|
|
1188
|
+
const success = await session.attempt(
|
|
1189
|
+
{ email: body.email, password: body.password },
|
|
1190
|
+
{ remember: body.remember ?? false },
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
if (!success) {
|
|
1194
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid email or password.' });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return { success: true };
|
|
1198
|
+
});
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
**CSRF:** Nuxt has NO built-in CSRF protection. Implement Origin header validation as server middleware, or use the `nuxt-security` module.
|
|
1202
|
+
|
|
1203
|
+
---
|
|
1204
|
+
|
|
1205
|
+
### TanStack Start
|
|
1206
|
+
|
|
1207
|
+
```typescript
|
|
1208
|
+
// app/lib/cookies.ts
|
|
1209
|
+
import { getCookie, setCookie, deleteCookie } from 'vinxi/http';
|
|
1210
|
+
import type { CookieBridge } from 'ideal-auth';
|
|
1211
|
+
|
|
1212
|
+
export function createCookieBridge(): CookieBridge {
|
|
1213
|
+
return {
|
|
1214
|
+
get(name: string) {
|
|
1215
|
+
return getCookie(name);
|
|
1216
|
+
},
|
|
1217
|
+
set(name, value, options) {
|
|
1218
|
+
setCookie(name, value, options);
|
|
1219
|
+
},
|
|
1220
|
+
delete(name) {
|
|
1221
|
+
deleteCookie(name, { path: '/' });
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
**IMPORTANT:** `vinxi/http` cookie functions use async local storage — must be called within a `createServerFn` handler or server middleware.
|
|
1228
|
+
|
|
1229
|
+
Auth setup:
|
|
1230
|
+
|
|
1231
|
+
```typescript
|
|
1232
|
+
// app/lib/auth.ts
|
|
1233
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
1234
|
+
import { createCookieBridge } from './cookies';
|
|
1235
|
+
import { db } from './db';
|
|
1236
|
+
|
|
1237
|
+
type User = { id: string; email: string; name: string; password: string };
|
|
1238
|
+
|
|
1239
|
+
export const hash = createHash({ rounds: 12 });
|
|
1240
|
+
|
|
1241
|
+
const authFactory = createAuth<User>({
|
|
1242
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
1243
|
+
cookie: createCookieBridge(),
|
|
1244
|
+
hash,
|
|
1245
|
+
|
|
1246
|
+
async resolveUser(id) {
|
|
1247
|
+
return db.user.findUnique({ where: { id } });
|
|
1248
|
+
},
|
|
1249
|
+
|
|
1250
|
+
async resolveUserByCredentials(credentials) {
|
|
1251
|
+
return db.user.findUnique({ where: { email: credentials.email } });
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
export function auth() {
|
|
1256
|
+
return authFactory();
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
Server functions:
|
|
1261
|
+
|
|
1262
|
+
```typescript
|
|
1263
|
+
// app/lib/auth.actions.ts
|
|
1264
|
+
import { createServerFn } from '@tanstack/start';
|
|
1265
|
+
import { auth, hash } from './auth';
|
|
1266
|
+
import { db } from './db';
|
|
1267
|
+
|
|
1268
|
+
export const loginFn = createServerFn({ method: 'POST' })
|
|
1269
|
+
.validator((data: { email: string; password: string; remember?: boolean }) => data)
|
|
1270
|
+
.handler(async ({ data }) => {
|
|
1271
|
+
if (!data.email || !data.password) throw new Error('Email and password are required.');
|
|
1272
|
+
|
|
1273
|
+
const session = auth();
|
|
1274
|
+
const success = await session.attempt(
|
|
1275
|
+
{ email: data.email, password: data.password },
|
|
1276
|
+
{ remember: data.remember ?? false },
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
if (!success) throw new Error('Invalid email or password.');
|
|
1280
|
+
|
|
1281
|
+
return { success: true };
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
export const getCurrentUserFn = createServerFn({ method: 'GET' })
|
|
1285
|
+
.handler(async () => {
|
|
1286
|
+
const session = auth();
|
|
1287
|
+
const user = await session.user();
|
|
1288
|
+
|
|
1289
|
+
if (!user) return { user: null };
|
|
1290
|
+
|
|
1291
|
+
return { user: { id: user.id, email: user.email, name: user.name } };
|
|
1292
|
+
});
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
Route protection with `beforeLoad`:
|
|
1296
|
+
|
|
1297
|
+
```tsx
|
|
1298
|
+
// app/routes/dashboard.tsx
|
|
1299
|
+
import { createFileRoute, redirect } from '@tanstack/react-router';
|
|
1300
|
+
import { getCurrentUserFn } from '../lib/auth.actions';
|
|
1301
|
+
|
|
1302
|
+
export const Route = createFileRoute('/dashboard')({
|
|
1303
|
+
beforeLoad: async () => {
|
|
1304
|
+
const { user } = await getCurrentUserFn();
|
|
1305
|
+
|
|
1306
|
+
if (!user) {
|
|
1307
|
+
throw redirect({ to: '/login', search: { callbackUrl: '/dashboard' } });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return { user };
|
|
1311
|
+
},
|
|
1312
|
+
component: DashboardPage,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
function DashboardPage() {
|
|
1316
|
+
const { user } = Route.useRouteContext();
|
|
1317
|
+
return <h1>Welcome, {user.name}</h1>;
|
|
1318
|
+
}
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
**CSRF:** TanStack Start has NO built-in CSRF protection. Validate the Origin header manually.
|
|
1322
|
+
|
|
1323
|
+
---
|
|
1324
|
+
|
|
1325
|
+
### Elysia
|
|
1326
|
+
|
|
1327
|
+
```typescript
|
|
1328
|
+
// src/lib/auth.ts
|
|
1329
|
+
import { createAuth, createHash } from 'ideal-auth';
|
|
1330
|
+
import type { Context } from 'elysia';
|
|
1331
|
+
import { db } from './db';
|
|
1332
|
+
|
|
1333
|
+
export const hash = createHash({ rounds: 12 });
|
|
1334
|
+
|
|
1335
|
+
export function auth(ctx: Context) {
|
|
1336
|
+
const { cookie } = ctx;
|
|
1337
|
+
|
|
1338
|
+
return createAuth({
|
|
1339
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
1340
|
+
|
|
1341
|
+
cookie: {
|
|
1342
|
+
get: (name) => cookie[name]?.value,
|
|
1343
|
+
set: (name, value, opts) => {
|
|
1344
|
+
cookie[name].set({
|
|
1345
|
+
value,
|
|
1346
|
+
httpOnly: opts.httpOnly,
|
|
1347
|
+
secure: opts.secure,
|
|
1348
|
+
sameSite: opts.sameSite,
|
|
1349
|
+
path: opts.path,
|
|
1350
|
+
maxAge: opts.maxAge,
|
|
1351
|
+
});
|
|
1352
|
+
},
|
|
1353
|
+
delete: (name) => cookie[name].remove(),
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1356
|
+
hash,
|
|
1357
|
+
|
|
1358
|
+
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
|
|
1359
|
+
|
|
1360
|
+
resolveUserByCredentials: async (creds) =>
|
|
1361
|
+
db.user.findUnique({ where: { email: creds.email } }),
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
**Note:** `auth(ctx)` returns the factory. Call `auth(ctx)()` to get the instance.
|
|
1367
|
+
|
|
1368
|
+
Auth middleware with `derive`:
|
|
1369
|
+
|
|
1370
|
+
```typescript
|
|
1371
|
+
// src/middleware/auth.ts
|
|
1372
|
+
import { Elysia } from 'elysia';
|
|
1373
|
+
import { auth } from '../lib/auth';
|
|
1374
|
+
|
|
1375
|
+
export const requireAuth = new Elysia({ name: 'requireAuth' })
|
|
1376
|
+
.derive(async (ctx) => {
|
|
1377
|
+
const session = auth(ctx)();
|
|
1378
|
+
const user = await session.user();
|
|
1379
|
+
|
|
1380
|
+
if (!user) {
|
|
1381
|
+
ctx.set.status = 401;
|
|
1382
|
+
throw new Error('Unauthorized');
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
return { user };
|
|
1386
|
+
});
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
**CSRF:** Elysia has no built-in CSRF. Validate Origin header via `onBeforeHandle`.
|
|
1390
|
+
|
|
1391
|
+
---
|
|
1392
|
+
|
|
1393
|
+
## Common Auth Flows
|
|
1394
|
+
|
|
1395
|
+
### Password Reset
|
|
1396
|
+
|
|
1397
|
+
```typescript
|
|
1398
|
+
import { createTokenVerifier, createHash } from 'ideal-auth';
|
|
1399
|
+
|
|
1400
|
+
const passwordReset = createTokenVerifier({
|
|
1401
|
+
secret: process.env.IDEAL_AUTH_SECRET! + '-reset', // use a different secret per use case
|
|
1402
|
+
expiryMs: 60 * 60 * 1000, // 1 hour
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// Step 1: User requests reset
|
|
1406
|
+
const token = passwordReset.createToken(user.id);
|
|
1407
|
+
await sendEmail(user.email, `https://app.com/reset/${token}`);
|
|
1408
|
+
// Put token in URL path, NOT query string (query strings are logged)
|
|
1409
|
+
|
|
1410
|
+
// Step 2: User clicks link
|
|
1411
|
+
const result = passwordReset.verifyToken(token);
|
|
1412
|
+
if (!result) throw new Error('Invalid or expired token');
|
|
1413
|
+
|
|
1414
|
+
// Step 3: Validate token hasn't been used (CRITICAL — tokens are stateless)
|
|
1415
|
+
if (result.iatMs < user.passwordChangedAt) {
|
|
1416
|
+
throw new Error('Token already used');
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Step 4: Update password
|
|
1420
|
+
const hash = createHash();
|
|
1421
|
+
await db.user.update({
|
|
1422
|
+
where: { id: result.userId },
|
|
1423
|
+
data: {
|
|
1424
|
+
password: await hash.make(newPassword),
|
|
1425
|
+
passwordChangedAt: Date.now(),
|
|
1426
|
+
},
|
|
1427
|
+
});
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
### Email Verification
|
|
1431
|
+
|
|
1432
|
+
```typescript
|
|
1433
|
+
const emailVerification = createTokenVerifier({
|
|
1434
|
+
secret: process.env.IDEAL_AUTH_SECRET! + '-email',
|
|
1435
|
+
expiryMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
// After registration
|
|
1439
|
+
const token = emailVerification.createToken(user.id);
|
|
1440
|
+
await sendEmail(user.email, `https://app.com/verify/${token}`);
|
|
1441
|
+
|
|
1442
|
+
// Verify
|
|
1443
|
+
const result = emailVerification.verifyToken(token);
|
|
1444
|
+
if (!result) throw new Error('Invalid or expired token');
|
|
1445
|
+
|
|
1446
|
+
await db.user.update({
|
|
1447
|
+
where: { id: result.userId },
|
|
1448
|
+
data: { emailVerifiedAt: new Date() },
|
|
1449
|
+
});
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
### Two-Factor Authentication (TOTP)
|
|
1453
|
+
|
|
1454
|
+
**Setup phase:**
|
|
1455
|
+
|
|
1456
|
+
```typescript
|
|
1457
|
+
import { createTOTP, createHash, encrypt, generateRecoveryCodes } from 'ideal-auth';
|
|
1458
|
+
|
|
1459
|
+
const totp = createTOTP();
|
|
1460
|
+
const hash = createHash();
|
|
1461
|
+
|
|
1462
|
+
// 1. Generate secret
|
|
1463
|
+
const secret = totp.generateSecret();
|
|
1464
|
+
|
|
1465
|
+
// 2. Create QR code URI
|
|
1466
|
+
const uri = totp.generateQrUri({
|
|
1467
|
+
secret,
|
|
1468
|
+
issuer: 'MyApp',
|
|
1469
|
+
account: user.email,
|
|
1470
|
+
});
|
|
1471
|
+
// Render uri as QR code with any QR library
|
|
1472
|
+
|
|
1473
|
+
// 3. Verify user can produce a valid code
|
|
1474
|
+
if (!totp.verify(codeFromAuthenticator, secret)) {
|
|
1475
|
+
throw new Error('Invalid setup code');
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// 4. Store secret encrypted
|
|
1479
|
+
await db.user.update({
|
|
1480
|
+
where: { id: user.id },
|
|
1481
|
+
data: {
|
|
1482
|
+
totpSecret: await encrypt(secret, process.env.ENCRYPTION_KEY!),
|
|
1483
|
+
totpEnabled: true,
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// 5. Generate recovery codes
|
|
1488
|
+
const { codes, hashed } = await generateRecoveryCodes(hash, 8);
|
|
1489
|
+
// Show codes to user ONCE, store hashed in DB
|
|
1490
|
+
await db.user.update({
|
|
1491
|
+
where: { id: user.id },
|
|
1492
|
+
data: { recoveryCodes: hashed },
|
|
1493
|
+
});
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
**Login with 2FA:**
|
|
1497
|
+
|
|
1498
|
+
```typescript
|
|
1499
|
+
import { decrypt } from 'ideal-auth';
|
|
1500
|
+
|
|
1501
|
+
// After password verification, check if 2FA is enabled
|
|
1502
|
+
if (user.totpEnabled) {
|
|
1503
|
+
const decryptedSecret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY!);
|
|
1504
|
+
|
|
1505
|
+
if (!totp.verify(codeFromUser, decryptedSecret)) {
|
|
1506
|
+
throw new Error('Invalid 2FA code');
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
await auth().login(user);
|
|
1511
|
+
```
|
|
1512
|
+
|
|
1513
|
+
**Recovery code login:**
|
|
1514
|
+
|
|
1515
|
+
```typescript
|
|
1516
|
+
import { verifyRecoveryCode } from 'ideal-auth';
|
|
1517
|
+
|
|
1518
|
+
const { valid, remaining } = await verifyRecoveryCode(code, user.recoveryCodes, hash);
|
|
1519
|
+
if (valid) {
|
|
1520
|
+
await db.user.update({
|
|
1521
|
+
where: { id: user.id },
|
|
1522
|
+
data: { recoveryCodes: remaining },
|
|
1523
|
+
});
|
|
1524
|
+
await auth().login(user);
|
|
1525
|
+
}
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
### Rate-Limited Login (Next.js example)
|
|
1529
|
+
|
|
1530
|
+
```typescript
|
|
1531
|
+
'use server';
|
|
1532
|
+
|
|
1533
|
+
import { redirect } from 'next/navigation';
|
|
1534
|
+
import { headers } from 'next/headers';
|
|
1535
|
+
import { auth } from '@/lib/auth';
|
|
1536
|
+
import { createRateLimiter } from 'ideal-auth';
|
|
1537
|
+
|
|
1538
|
+
const limiter = createRateLimiter({
|
|
1539
|
+
maxAttempts: 5,
|
|
1540
|
+
windowMs: 60_000, // 1 minute
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
export async function loginAction(formData: FormData) {
|
|
1544
|
+
const email = formData.get('email') as string;
|
|
1545
|
+
const password = formData.get('password') as string;
|
|
1546
|
+
|
|
1547
|
+
const headerStore = await headers();
|
|
1548
|
+
const ip = headerStore.get('x-forwarded-for') ?? '127.0.0.1';
|
|
1549
|
+
const key = `login:${ip}`;
|
|
1550
|
+
|
|
1551
|
+
const { allowed, remaining, resetAt } = await limiter.attempt(key);
|
|
1552
|
+
|
|
1553
|
+
if (!allowed) {
|
|
1554
|
+
const seconds = Math.ceil((resetAt.getTime() - Date.now()) / 1000);
|
|
1555
|
+
redirect(`/?error=rate_limit&retry=${seconds}`);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const session = auth();
|
|
1559
|
+
const success = await session.attempt({ email, password });
|
|
1560
|
+
|
|
1561
|
+
if (!success) {
|
|
1562
|
+
redirect(`/?error=invalid&remaining=${remaining}`);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
await limiter.reset(key);
|
|
1566
|
+
redirect('/');
|
|
1567
|
+
}
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
### Remember Me
|
|
1571
|
+
|
|
1572
|
+
```typescript
|
|
1573
|
+
// Session cookie (expires when browser closes)
|
|
1574
|
+
await auth().login(user, { remember: false });
|
|
1575
|
+
|
|
1576
|
+
// Default (7 days)
|
|
1577
|
+
await auth().login(user);
|
|
1578
|
+
|
|
1579
|
+
// Persistent (30 days)
|
|
1580
|
+
await auth().login(user, { remember: true });
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
---
|
|
1584
|
+
|
|
1585
|
+
## Open Redirect Prevention
|
|
1586
|
+
|
|
1587
|
+
Always validate redirect URLs after login. Never redirect to user-supplied absolute URLs.
|
|
1588
|
+
|
|
1589
|
+
```typescript
|
|
1590
|
+
// lib/safe-redirect.ts
|
|
1591
|
+
export function safeRedirect(url: string | null | undefined, fallback = '/'): string {
|
|
1592
|
+
if (
|
|
1593
|
+
!url ||
|
|
1594
|
+
!url.startsWith('/') ||
|
|
1595
|
+
url.startsWith('//') ||
|
|
1596
|
+
url.startsWith('/\\') ||
|
|
1597
|
+
url.includes('://')
|
|
1598
|
+
) {
|
|
1599
|
+
return fallback;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
return url;
|
|
1603
|
+
}
|
|
1604
|
+
```
|
|
1605
|
+
|
|
1606
|
+
Use it in login actions:
|
|
1607
|
+
|
|
1608
|
+
```typescript
|
|
1609
|
+
import { safeRedirect } from '@/lib/safe-redirect';
|
|
1610
|
+
|
|
1611
|
+
// After successful login
|
|
1612
|
+
redirect(safeRedirect(redirectTo, '/dashboard'));
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
---
|
|
1616
|
+
|
|
1617
|
+
## Security Rules
|
|
1618
|
+
|
|
1619
|
+
### Always enforced by ideal-auth:
|
|
1620
|
+
- `httpOnly: true` on session cookies — forced at runtime, cannot be overridden
|
|
1621
|
+
- `secure: true` when `NODE_ENV === 'production'`
|
|
1622
|
+
- `sameSite: 'lax'` by default
|
|
1623
|
+
- `path: '/'` by default
|
|
1624
|
+
- Secret must be 32+ characters — throws at startup if shorter
|
|
1625
|
+
- SHA-256 prehash for passwords > 72 bytes (prevents silent bcrypt truncation)
|
|
1626
|
+
- Timing-safe comparison for all secret/signature/TOTP operations
|
|
1627
|
+
|
|
1628
|
+
### Your responsibility:
|
|
1629
|
+
- CSRF protection (framework-dependent — see each framework section)
|
|
1630
|
+
- Open redirect prevention (use `safeRedirect`)
|
|
1631
|
+
- Password minimum length enforcement (NIST recommends 8+)
|
|
1632
|
+
- Rate limiting on login, registration, and password reset endpoints
|
|
1633
|
+
- Using a persistent rate limit store in production (not in-memory)
|
|
1634
|
+
- Encrypting TOTP secrets at rest
|
|
1635
|
+
- Checking `iatMs` on tokens to prevent reuse after password change
|
|
1636
|
+
- Using different token secrets per use case
|
|
1637
|
+
- Setting `NODE_ENV=production` in production
|
|
1638
|
+
- Never logging passwords
|
|
1639
|
+
- Using parameterized queries/ORM (SQL injection prevention)
|
|
1640
|
+
- Content sanitization (XSS prevention)
|
|
1641
|
+
|
|
1642
|
+
### CSRF by framework:
|
|
1643
|
+
| Framework | CSRF Protection |
|
|
1644
|
+
|-----------|----------------|
|
|
1645
|
+
| Next.js Server Actions | Built-in (automatic Origin validation) |
|
|
1646
|
+
| Next.js API Routes | Manual Origin validation needed |
|
|
1647
|
+
| SvelteKit form actions | Built-in (automatic Origin validation) |
|
|
1648
|
+
| Hono | Built-in `csrf()` middleware |
|
|
1649
|
+
| Express | Manual — implement Origin validation middleware |
|
|
1650
|
+
| Nuxt | Manual — implement Origin validation or use `nuxt-security` |
|
|
1651
|
+
| TanStack Start | Manual — validate Origin in server functions or middleware |
|
|
1652
|
+
| Elysia | Manual — validate Origin in `onBeforeHandle` |
|
|
1653
|
+
|
|
1654
|
+
### Configuration Defaults
|
|
1655
|
+
|
|
1656
|
+
| Setting | Default | Notes |
|
|
1657
|
+
|---------|---------|-------|
|
|
1658
|
+
| Session secret | 32+ chars | Validated at startup |
|
|
1659
|
+
| Cookie name | `ideal_session` | Customizable |
|
|
1660
|
+
| Session maxAge | 604,800s (7 days) | Standard session |
|
|
1661
|
+
| Remember maxAge | 2,592,000s (30 days) | Remember me |
|
|
1662
|
+
| Cookie secure | `NODE_ENV === 'production'` | Auto |
|
|
1663
|
+
| Cookie sameSite | `lax` | CSRF protection |
|
|
1664
|
+
| Cookie path | `/` | Full domain |
|
|
1665
|
+
| Cookie httpOnly | `true` | Forced, not configurable |
|
|
1666
|
+
| Bcrypt rounds | 12 | ~250ms per hash |
|
|
1667
|
+
| TOTP digits | 6 | Standard |
|
|
1668
|
+
| TOTP period | 30s | RFC 6238 |
|
|
1669
|
+
| TOTP window | 1 | ±1 step (~90s acceptance) |
|
|
1670
|
+
| Token expiry | 3,600,000ms (1h) | Configurable |
|
|
1671
|
+
| Rate limit store | MemoryRateLimitStore | Use Redis/DB in prod |
|
|
1672
|
+
|
|
1673
|
+
---
|
|
1674
|
+
|
|
1675
|
+
## Troubleshooting
|
|
1676
|
+
|
|
1677
|
+
### Session not persisting
|
|
1678
|
+
- **Next.js 15+:** Ensure `cookies()` is `await`ed in the cookie bridge
|
|
1679
|
+
- **Cookie bridge:** Ensure `set()` passes all three args (name, value, options) to the framework
|
|
1680
|
+
- **Cookie path:** Default is `'/'` — if overridden, ensure it covers all routes
|
|
1681
|
+
|
|
1682
|
+
### Login works in dev but not production
|
|
1683
|
+
- `IDEAL_AUTH_SECRET` must be set in production env vars
|
|
1684
|
+
- Secret must be identical across all server instances/deploys
|
|
1685
|
+
- `NODE_ENV=production` must be set (controls `secure` cookie flag)
|
|
1686
|
+
- HTTPS must be enabled (secure cookies only sent over HTTPS)
|
|
1687
|
+
|
|
1688
|
+
### `attempt()` always returns false
|
|
1689
|
+
- Check `resolveUserByCredentials` returns the user object (not null)
|
|
1690
|
+
- Ensure user has a `password` field with a bcrypt hash (starts with `$2a$` or `$2b$`)
|
|
1691
|
+
- If your password column is named differently, set `passwordField`
|
|
1692
|
+
- If your credential key is not `password`, set `credentialKey`
|
|
1693
|
+
|
|
1694
|
+
### Cookie not set on localhost
|
|
1695
|
+
- Don't set `secure: true` explicitly in dev (default is `false` when not production)
|
|
1696
|
+
- `sameSite: 'none'` requires `secure: true`
|
|
1697
|
+
- Frontend and API on different ports? Browser may block as third-party cookie
|
|
1698
|
+
|
|
1699
|
+
### TypeScript errors with user type
|
|
1700
|
+
- Pass your user type as generic: `createAuth<User>({ ... })`
|
|
1701
|
+
- User type must have `id: string | number`
|
|
1702
|
+
|
|
1703
|
+
### TOTP codes not verifying
|
|
1704
|
+
- Server clock must be synced (use NTP)
|
|
1705
|
+
- Don't set `window: 0` — too strict for real-world use
|
|
1706
|
+
- Verify the TOTP secret round-trips correctly through storage
|
|
1707
|
+
|
|
1708
|
+
### Rate limiter not working in production
|
|
1709
|
+
- `MemoryRateLimitStore` resets on process restart and is per-process
|
|
1710
|
+
- Use Redis-backed store for serverless/multi-instance deployments
|
|
1711
|
+
|
|
1712
|
+
### Token verifier returns null
|
|
1713
|
+
- Same secret must be used for creation and verification
|
|
1714
|
+
- Check if token has expired (default: 1 hour)
|
|
1715
|
+
- Secret rotation invalidates all outstanding tokens
|
|
1716
|
+
|
|
1717
|
+
---
|
|
1718
|
+
|
|
1719
|
+
## Token Refresh (OAuth Access Tokens)
|
|
1720
|
+
|
|
1721
|
+
When using `sessionFields` to store OAuth access tokens in the session cookie, refresh them proactively before they expire. Do NOT wait for a 401 — by then the user's request already failed.
|
|
1722
|
+
|
|
1723
|
+
### Setup
|
|
1724
|
+
|
|
1725
|
+
```ts
|
|
1726
|
+
sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'expiresAt'],
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
Store `expiresAt` (Unix seconds) at login time: `Math.floor(Date.now() / 1000) + tokens.expires_in`.
|
|
1730
|
+
|
|
1731
|
+
### Proactive refresh pattern
|
|
1732
|
+
|
|
1733
|
+
```ts
|
|
1734
|
+
const REFRESH_BUFFER_SECONDS = 60;
|
|
1735
|
+
|
|
1736
|
+
async function ensureFreshToken() {
|
|
1737
|
+
const session = auth();
|
|
1738
|
+
const user = await session.user();
|
|
1739
|
+
if (!user) return null;
|
|
1740
|
+
|
|
1741
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1742
|
+
if (user.expiresAt > now + REFRESH_BUFFER_SECONDS) return user; // still fresh
|
|
1743
|
+
|
|
1744
|
+
// Refresh the token
|
|
1745
|
+
const tokens = await refreshAccessToken(user.refreshToken);
|
|
1746
|
+
if (!tokens) { await session.logout(); return null; } // refresh failed
|
|
1747
|
+
|
|
1748
|
+
// Update session cookie with new tokens
|
|
1749
|
+
const updated = { ...user, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: now + tokens.expiresIn };
|
|
1750
|
+
await session.login(updated);
|
|
1751
|
+
return updated;
|
|
1752
|
+
}
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
Key point: `auth().login()` replaces the session cookie — calling it with updated tokens IS the refresh mechanism.
|
|
1756
|
+
|
|
1757
|
+
### Rules
|
|
1758
|
+
- Check `expiresAt` **before** making API calls, not after a 401
|
|
1759
|
+
- Use a 60-second buffer before expiry to prevent race conditions
|
|
1760
|
+
- When refresh fails, `logout()` and redirect to login
|
|
1761
|
+
- For refresh token rotation, prefer the new refresh token from the response
|
|
1762
|
+
- In high-concurrency environments, use a lock/mutex to prevent multiple simultaneous refreshes
|
|
1763
|
+
|
|
1764
|
+
---
|
|
1765
|
+
|
|
1766
|
+
## Multi-Tenant Cross-Domain Authentication
|
|
1767
|
+
|
|
1768
|
+
When tenants run on different domains (e.g., `acme.app.com`, `widgets.app.com`), cookies set on the central login domain cannot be read by tenant domains. Use a short-lived, one-time-use database token to transfer authentication across domains.
|
|
1769
|
+
|
|
1770
|
+
### When to use this pattern
|
|
1771
|
+
|
|
1772
|
+
- Tenants are on **separate domains** (not subdomains of a shared parent)
|
|
1773
|
+
- Authentication happens on a **central login page** (identity domain)
|
|
1774
|
+
- OAuth providers redirect to the central app, not individual tenants
|
|
1775
|
+
- Cookies **cannot be shared** across tenant domains
|
|
1776
|
+
|
|
1777
|
+
If all tenants share a parent domain (e.g., `*.app.com`), set `domain: '.app.com'` on the session cookie instead — no cross-domain flow needed.
|
|
1778
|
+
|
|
1779
|
+
### Database schema
|
|
1780
|
+
|
|
1781
|
+
```sql
|
|
1782
|
+
CREATE TABLE login_sessions (
|
|
1783
|
+
id TEXT PRIMARY KEY, -- random lookup key
|
|
1784
|
+
token_hash TEXT NOT NULL, -- HMAC-SHA256 of the validation token
|
|
1785
|
+
user_id TEXT NOT NULL, -- user who authenticated
|
|
1786
|
+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
|
1787
|
+
);
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
Two-part design: `id` for fast DB lookup, `token_hash` for cryptographic verification. Plaintext token is never stored — DB breach does not expose usable tokens. `userId` is stored plaintext (not a secret — no encryption overhead needed).
|
|
1791
|
+
|
|
1792
|
+
### Transfer token module
|
|
1793
|
+
|
|
1794
|
+
```ts
|
|
1795
|
+
import { generateToken, signData, timingSafeEqual } from 'ideal-auth';
|
|
1796
|
+
|
|
1797
|
+
const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
1798
|
+
const secret = process.env.TRANSFER_TOKEN_SECRET!; // separate from IDEAL_AUTH_SECRET
|
|
1799
|
+
|
|
1800
|
+
// After successful authentication on central app
|
|
1801
|
+
async function createTransferToken(userId: string) {
|
|
1802
|
+
const id = generateToken(20); // 40 hex chars — lookup key
|
|
1803
|
+
const token = generateToken(32); // 64 hex chars — validation secret
|
|
1804
|
+
const tokenHash = signData(token, secret);
|
|
1805
|
+
await db.insert(loginSessions).values({ id, tokenHash, userId });
|
|
1806
|
+
return { id, token }; // both included in redirect URL
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// On tenant callback endpoint
|
|
1810
|
+
async function validateTransferToken(id: string, token: string) {
|
|
1811
|
+
const deleted = await db.delete(loginSessions).where(eq(loginSessions.id, id))
|
|
1812
|
+
.returning({ tokenHash: loginSessions.tokenHash, userId: loginSessions.userId, createdAt: loginSessions.createdAt });
|
|
1813
|
+
if (deleted.length === 0) return null;
|
|
1814
|
+
const row = deleted[0];
|
|
1815
|
+
if (row.createdAt.getTime() < Date.now() - TOKEN_EXPIRY_MS) return null; // expired
|
|
1816
|
+
const candidateHash = signData(token, secret);
|
|
1817
|
+
if (!timingSafeEqual(candidateHash, row.tokenHash)) return null; // invalid token
|
|
1818
|
+
return { userId: row.userId };
|
|
1819
|
+
}
|
|
1820
|
+
```
|
|
1821
|
+
|
|
1822
|
+
### Tenant domain allowlist (central app)
|
|
1823
|
+
|
|
1824
|
+
```ts
|
|
1825
|
+
// Central app MUST validate callbackUrl before redirecting
|
|
1826
|
+
const ALLOWED_TENANT_DOMAINS = process.env.ALLOWED_TENANT_DOMAINS!.split(',');
|
|
1827
|
+
|
|
1828
|
+
function validateTenantCallbackUrl(url: string | null): string | null {
|
|
1829
|
+
if (!url) return null;
|
|
1830
|
+
try {
|
|
1831
|
+
const parsed = new URL(url);
|
|
1832
|
+
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') return null;
|
|
1833
|
+
if (!ALLOWED_TENANT_DOMAINS.includes(parsed.host)) return null;
|
|
1834
|
+
return url;
|
|
1835
|
+
} catch { return null; }
|
|
1836
|
+
}
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
### Tenant auth setup with `attemptUser`
|
|
1840
|
+
|
|
1841
|
+
**With database (resolveUser):**
|
|
1842
|
+
```ts
|
|
1843
|
+
const auth = createAuth<User>({
|
|
1844
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
1845
|
+
cookie: createCookieBridge(),
|
|
1846
|
+
resolveUser: async (id) => db.user.findUnique({ where: { id } }),
|
|
1847
|
+
attemptUser: async (credentials) => {
|
|
1848
|
+
const data = await validateTransferToken(credentials.id, credentials.token);
|
|
1849
|
+
if (!data) return null;
|
|
1850
|
+
let user = await db.user.findUnique({ where: { id: data.userId } });
|
|
1851
|
+
if (!user) user = await db.user.create({ data: { id: data.userId } });
|
|
1852
|
+
return user;
|
|
1853
|
+
},
|
|
1854
|
+
});
|
|
1855
|
+
```
|
|
1856
|
+
|
|
1857
|
+
**Without database (sessionFields) — user info stored in cookie:**
|
|
1858
|
+
```ts
|
|
1859
|
+
const auth = createAuth<User>({
|
|
1860
|
+
secret: process.env.IDEAL_AUTH_SECRET!,
|
|
1861
|
+
cookie: createCookieBridge(),
|
|
1862
|
+
sessionFields: ['email', 'name', 'accessToken'],
|
|
1863
|
+
attemptUser: async (credentials) => {
|
|
1864
|
+
const data = await validateTransferToken(credentials.id, credentials.token);
|
|
1865
|
+
if (!data) return null;
|
|
1866
|
+
// Fetch profile from identity provider using the access token
|
|
1867
|
+
const res = await fetch('https://identity.example.com/api/userinfo', {
|
|
1868
|
+
headers: { Authorization: `Bearer ${data.accessToken}` },
|
|
1869
|
+
});
|
|
1870
|
+
if (!res.ok) return null;
|
|
1871
|
+
const profile = await res.json();
|
|
1872
|
+
return { id: profile.sub, email: profile.email, name: profile.name, accessToken: data.accessToken };
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
// user() returns { id, email, name, accessToken } from cookie — zero API calls after login
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
### Tenant callback endpoint
|
|
1879
|
+
|
|
1880
|
+
```ts
|
|
1881
|
+
// GET /api/auth/callback?id=xxx&token=yyy&callbackUrl=/dashboard
|
|
1882
|
+
const id = request.searchParams.get('id');
|
|
1883
|
+
const token = request.searchParams.get('token');
|
|
1884
|
+
if (!id || !token) redirect('/login-failed');
|
|
1885
|
+
const session = auth();
|
|
1886
|
+
const success = await session.attempt({ id, token });
|
|
1887
|
+
if (success) redirect(safeRedirect(callbackUrl, '/dashboard'));
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
### Central login redirect flow
|
|
1891
|
+
|
|
1892
|
+
```
|
|
1893
|
+
Tenant (no session) → redirect to central login with callbackUrl
|
|
1894
|
+
Central login → validate callbackUrl against tenant allowlist → authenticate
|
|
1895
|
+
→ createTransferToken(userId) → redirect to tenant callback with id + token
|
|
1896
|
+
Tenant callback → validateTransferToken(id, token) → attemptUser → session created
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
### Security rules for multi-tenant auth
|
|
1900
|
+
|
|
1901
|
+
- **Validate callbackUrl first**: Central app must check against tenant domain allowlist before any redirect — this is the most critical check
|
|
1902
|
+
- **Two-part token**: Separate `id` (lookup) and `token` (validation) — neither alone is useful
|
|
1903
|
+
- **Timing-safe verification**: Use `timingSafeEqual` to compare HMAC hashes, preventing timing attacks
|
|
1904
|
+
- **Separate secret**: `TRANSFER_TOKEN_SECRET` must be different from `IDEAL_AUTH_SECRET`
|
|
1905
|
+
- **Same secret on all apps**: Central and all tenants share `TRANSFER_TOKEN_SECRET`
|
|
1906
|
+
- **One-time use**: Delete token from DB immediately after validation
|
|
1907
|
+
- **5-minute expiry**: Reject tokens older than 5 minutes
|
|
1908
|
+
- **HTTPS required**: Token travels in redirect URL — must be encrypted in transit
|
|
1909
|
+
- **Rate limit callback**: 10 attempts/minute per IP on the callback endpoint
|
|
1910
|
+
- **Clean up expired tokens**: Run a cron job hourly to delete old rows
|
|
1911
|
+
- **No payload encryption needed**: `userId` is not a secret — the real protection is the HMAC'd token, one-time use, and short expiry
|
|
1912
|
+
- **API endpoint auth**: If tenants validate via API (not shared DB), protect with API key or mTLS
|
|
1913
|
+
|
|
1914
|
+
---
|
|
1915
|
+
|
|
1916
|
+
## Federated Logout
|
|
1917
|
+
|
|
1918
|
+
When authenticating through a central identity provider (OAuth, OIDC, or custom login service), logging out requires a redirect chain that clears sessions on every domain.
|
|
1919
|
+
|
|
1920
|
+
### Flow
|
|
1921
|
+
|
|
1922
|
+
```
|
|
1923
|
+
Tenant: auth().logout() → clear local cookie → redirect to central /logout
|
|
1924
|
+
Central: auth().logout() → clear central cookie → redirect to OIDC provider logout (if applicable)
|
|
1925
|
+
Provider: clear provider session → redirect to post_logout_redirect_uri
|
|
1926
|
+
Final: redirect back to tenant origin
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
### Tenant logout action
|
|
1930
|
+
|
|
1931
|
+
```ts
|
|
1932
|
+
export async function logoutAction() {
|
|
1933
|
+
const session = auth();
|
|
1934
|
+
const user = await session.user();
|
|
1935
|
+
const idToken = user?.idToken; // needed for OIDC provider logout
|
|
1936
|
+
|
|
1937
|
+
await session.logout();
|
|
1938
|
+
|
|
1939
|
+
const logoutUrl = new URL(`${process.env.CENTRAL_LOGIN_URL}/logout`);
|
|
1940
|
+
logoutUrl.searchParams.set('callbackUrl', process.env.NEXT_PUBLIC_APP_URL!);
|
|
1941
|
+
if (idToken) logoutUrl.searchParams.set('id_token_hint', idToken);
|
|
1942
|
+
redirect(logoutUrl.toString());
|
|
1943
|
+
}
|
|
1944
|
+
```
|
|
1945
|
+
|
|
1946
|
+
### Central logout endpoint
|
|
1947
|
+
|
|
1948
|
+
```ts
|
|
1949
|
+
// Clear central session, then redirect to OIDC provider (or back to tenant if no provider)
|
|
1950
|
+
const session = auth();
|
|
1951
|
+
await session.logout();
|
|
1952
|
+
|
|
1953
|
+
// With OIDC provider:
|
|
1954
|
+
const params = new URLSearchParams({ post_logout_redirect_uri: `${AUTH_URL}/logout/callback` });
|
|
1955
|
+
if (idTokenHint) params.set('id_token_hint', idTokenHint);
|
|
1956
|
+
redirect(`${OIDC_ISSUER_URL}/connect/logout?${params}`);
|
|
1957
|
+
|
|
1958
|
+
// Without OIDC provider:
|
|
1959
|
+
redirect(callbackUrl);
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
### Key rules
|
|
1963
|
+
- `auth().logout()` only clears the **current domain's** cookie — cannot clear cookies on other domains
|
|
1964
|
+
- Each domain in the chain must be visited to clear its session
|
|
1965
|
+
- Validate `callbackUrl` on central logout endpoint (same allowlist as login)
|
|
1966
|
+
- Use `id_token_hint` for OIDC provider logout — without it, some providers show a confirmation page
|
|
1967
|
+
- For token revocation, call the provider's revocation endpoint before `logout()`
|
|
1968
|
+
- If you need instant revocation across all domains, add a server-side `sessions` table
|
|
1969
|
+
- Store `idToken` in `sessionFields` if needed for OIDC logout
|
|
1970
|
+
|
|
1971
|
+
---
|
|
1972
|
+
|
|
1973
|
+
## Production Checklist
|
|
1974
|
+
|
|
1975
|
+
Before deploying, verify:
|
|
1976
|
+
|
|
1977
|
+
- [ ] `IDEAL_AUTH_SECRET` is set, 32+ chars, not in version control
|
|
1978
|
+
- [ ] `NODE_ENV=production` is set
|
|
1979
|
+
- [ ] HTTPS is enabled
|
|
1980
|
+
- [ ] Session `maxAge` is appropriate for your use case
|
|
1981
|
+
- [ ] `httpOnly` is forced (default — do not strip in cookie bridge)
|
|
1982
|
+
- [ ] `secure` cookie flag is `true` in production
|
|
1983
|
+
- [ ] `sameSite` is `lax` (default) — only change if you understand the implications
|
|
1984
|
+
- [ ] Bcrypt rounds are 12+ (default)
|
|
1985
|
+
- [ ] Password minimum length is enforced (8+ characters)
|
|
1986
|
+
- [ ] Passwords are never logged
|
|
1987
|
+
- [ ] Login endpoint is rate limited
|
|
1988
|
+
- [ ] Registration endpoint is rate limited
|
|
1989
|
+
- [ ] Password reset endpoint is rate limited
|
|
1990
|
+
- [ ] Using persistent rate limit store in production (not in-memory)
|
|
1991
|
+
- [ ] CSRF protection is enabled for your framework
|
|
1992
|
+
- [ ] Token secrets are 32+ chars, different per use case
|
|
1993
|
+
- [ ] Token expiry is appropriate (reset: 1h, email: 24h, magic link: 15m)
|
|
1994
|
+
- [ ] Tokens are in URL paths, not query strings
|
|
1995
|
+
- [ ] Token `iatMs` is checked against relevant timestamps
|
|
1996
|
+
- [ ] TOTP secret is stored encrypted in database
|
|
1997
|
+
- [ ] Recovery codes are shown once, stored hashed
|
|
1998
|
+
- [ ] Post-login redirects are validated with `safeRedirect`
|
|
1999
|
+
- [ ] Error messages don't leak user existence ("Invalid email or password")
|
|
2000
|
+
- [ ] Secret rotation plan exists for emergency session invalidation
|