ideal-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +533 -0
  3. package/dist/auth-instance.d.ts +17 -0
  4. package/dist/auth-instance.js +92 -0
  5. package/dist/auth.d.ts +2 -0
  6. package/dist/auth.js +25 -0
  7. package/dist/bin/ideal-auth.d.ts +2 -0
  8. package/dist/bin/ideal-auth.js +13 -0
  9. package/dist/crypto/encryption.d.ts +2 -0
  10. package/dist/crypto/encryption.js +59 -0
  11. package/dist/crypto/hmac.d.ts +2 -0
  12. package/dist/crypto/hmac.js +11 -0
  13. package/dist/crypto/timing-safe.d.ts +1 -0
  14. package/dist/crypto/timing-safe.js +16 -0
  15. package/dist/crypto/token.d.ts +1 -0
  16. package/dist/crypto/token.js +6 -0
  17. package/dist/hash/index.d.ts +2 -0
  18. package/dist/hash/index.js +23 -0
  19. package/dist/index.d.ts +12 -0
  20. package/dist/index.js +17 -0
  21. package/dist/rate-limit/index.d.ts +5 -0
  22. package/dist/rate-limit/index.js +15 -0
  23. package/dist/rate-limit/memory-store.d.ts +12 -0
  24. package/dist/rate-limit/memory-store.js +39 -0
  25. package/dist/rate-limit/types.d.ts +1 -0
  26. package/dist/rate-limit/types.js +1 -0
  27. package/dist/session/cookie.d.ts +2 -0
  28. package/dist/session/cookie.js +10 -0
  29. package/dist/session/seal.d.ts +3 -0
  30. package/dist/session/seal.js +19 -0
  31. package/dist/token-verifier/index.d.ts +2 -0
  32. package/dist/token-verifier/index.js +35 -0
  33. package/dist/totp/base32.d.ts +2 -0
  34. package/dist/totp/base32.js +36 -0
  35. package/dist/totp/index.d.ts +2 -0
  36. package/dist/totp/index.js +50 -0
  37. package/dist/totp/recovery.d.ts +6 -0
  38. package/dist/totp/recovery.js +19 -0
  39. package/dist/types.d.ts +106 -0
  40. package/dist/types.js +1 -0
  41. package/package.json +49 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramon Malcolm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,533 @@
1
+ # ideal-auth
2
+
3
+ Auth primitives for the JS ecosystem. Zero framework dependencies. Inspired by Laravel's `Auth` and `Hash` facades.
4
+
5
+ Provide a cookie bridge (3 functions) once during setup, and `auth().login(user)` just works — handles session creation, cookie encryption, and storage internally via [iron-session](https://github.com/vvo/iron-session).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add ideal-auth
11
+ ```
12
+
13
+ ## Generate Secret
14
+
15
+ ```bash
16
+ bunx ideal-auth secret
17
+ ```
18
+
19
+ ```
20
+ IDEAL_AUTH_SECRET=aLThikMgJKMBB5WZLE-lCaOQUdgPWU8BHRv99bkYaVY
21
+ ```
22
+
23
+ Copy the output into your `.env` file. The secret must be at least 32 characters.
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ // lib/auth.ts
29
+ import { createAuth, createHash } from 'ideal-auth';
30
+ import { cookies } from 'next/headers';
31
+ import { db } from '@/lib/db';
32
+
33
+ export const hash = createHash({ rounds: 12 });
34
+
35
+ export const auth = createAuth({
36
+ secret: process.env.IDEAL_AUTH_SECRET!, // 32+ characters
37
+
38
+ cookie: {
39
+ get: async (name) => (await cookies()).get(name)?.value,
40
+ set: async (name, value, opts) => (await cookies()).set(name, value, opts),
41
+ delete: async (name) => (await cookies()).delete(name),
42
+ },
43
+
44
+ hash,
45
+
46
+ resolveUser: async (id) => {
47
+ return db.user.findUnique({ where: { id } });
48
+ },
49
+
50
+ resolveUserByCredentials: async (credentials) => {
51
+ return db.user.findUnique({ where: { email: credentials.email } });
52
+ },
53
+ });
54
+ ```
55
+
56
+ ```typescript
57
+ // Server Action
58
+ 'use server';
59
+ import { auth } from '@/lib/auth';
60
+
61
+ // Call auth() once per request and reuse the instance — it caches the
62
+ // session payload and user, so subsequent calls avoid redundant work.
63
+ const session = auth();
64
+
65
+ // Login with credentials (password verified automatically)
66
+ const success = await session.attempt({ email, password });
67
+
68
+ // Login with a user object directly
69
+ await session.login(user);
70
+
71
+ // Login by user ID
72
+ await session.loginById('user-123');
73
+
74
+ // Check session
75
+ const isLoggedIn = await session.check();
76
+ const currentUser = await session.user();
77
+ const userId = await session.id();
78
+
79
+ // Logout
80
+ await session.logout();
81
+ ```
82
+
83
+ ## API
84
+
85
+ ### `createAuth(config)`
86
+
87
+ Returns a function `auth()` that creates an `AuthInstance` on each call.
88
+
89
+ #### Config
90
+
91
+ | Field | Type | Required | Default |
92
+ | --- | --- | --- | --- |
93
+ | `secret` | `string` | Yes | — |
94
+ | `cookie` | `CookieBridge` | Yes | — |
95
+ | `resolveUser` | `(id: string) => Promise<User \| null>` | Yes | — |
96
+ | `hash` | `HashInstance` | No | — |
97
+ | `resolveUserByCredentials` | `(creds: Record<string, any>) => Promise<User \| null>` | No | — |
98
+ | `credentialKey` | `string` | No | `'password'` |
99
+ | `passwordField` | `string` | No | `'password'` |
100
+ | `attemptUser` | `(creds: Record<string, any>) => Promise<User \| null>` | No | — |
101
+ | `session.cookieName` | `string` | No | `'ideal_session'` |
102
+ | `session.maxAge` | `number` (seconds) | No | `604800` (7 days) |
103
+ | `session.rememberMaxAge` | `number` (seconds) | No | `2592000` (30 days) |
104
+ | `session.cookie` | `Partial<ConfigurableCookieOptions>` | No | secure in prod, sameSite lax, path / (`httpOnly` is always `true` — not configurable) |
105
+
106
+ #### AuthInstance Methods
107
+
108
+ | Method | Returns | Description |
109
+ | --- | --- | --- |
110
+ | `login(user, options?)` | `Promise<void>` | Set session cookie for the given user |
111
+ | `loginById(id, options?)` | `Promise<void>` | Resolve user by ID, then set session cookie |
112
+ | `attempt(credentials, options?)` | `Promise<boolean>` | Find user, verify password, login if valid |
113
+ | `logout()` | `Promise<void>` | Delete session cookie |
114
+ | `check()` | `Promise<boolean>` | Is the session valid? |
115
+ | `user()` | `Promise<User \| null>` | Get the authenticated user |
116
+ | `id()` | `Promise<string \| null>` | Get the authenticated user's ID |
117
+
118
+ All login methods accept an optional `LoginOptions` object:
119
+
120
+ | Option | Type | Default | Description |
121
+ | --- | --- | --- | --- |
122
+ | `remember` | `boolean` | `undefined` | `true`: use `rememberMaxAge` (30 days). `false`: session cookie (expires when browser closes). Omitted: use default `maxAge` (7 days). |
123
+
124
+ ```typescript
125
+ const session = auth();
126
+
127
+ // Remember me — 30-day persistent cookie
128
+ await session.attempt({ email, password }, { remember: true });
129
+
130
+ // No remember — session cookie, expires when browser closes
131
+ await session.login(user, { remember: false });
132
+
133
+ // Default — 7-day cookie
134
+ await session.login(user);
135
+ ```
136
+
137
+ #### `attempt()` — Two Modes
138
+
139
+ **Laravel-style (recommended):** Provide `hash` and `resolveUserByCredentials` in config. `attempt()` strips the credential key (default `password`) from the credentials, looks up the user with the remaining fields, and calls `hash.verify()` against the stored hash automatically.
140
+
141
+ ```typescript
142
+ const auth = createAuth({
143
+ // ...
144
+ hash,
145
+ resolveUserByCredentials: async (creds) => {
146
+ return db.user.findUnique({ where: { email: creds.email } });
147
+ },
148
+ });
149
+
150
+ const session = auth();
151
+ await session.attempt({ email, password }); // password verified internally
152
+ ```
153
+
154
+ **Manual (escape hatch):** Provide `attemptUser` for full control over lookup and verification. Takes precedence over the Laravel-style config if both are provided.
155
+
156
+ ```typescript
157
+ const auth = createAuth({
158
+ // ...
159
+ attemptUser: async (creds) => {
160
+ const user = await db.user.findUnique({ where: { email: creds.email } });
161
+ if (!user) return null;
162
+ if (!(await hash.verify(creds.password, user.password))) return null;
163
+ return user;
164
+ },
165
+ });
166
+ ```
167
+
168
+ ---
169
+
170
+ ### `createHash(config?)`
171
+
172
+ Returns a `HashInstance` using bcrypt.
173
+
174
+ | Option | Type | Default |
175
+ | --- | --- | --- |
176
+ | `rounds` | `number` | `12` |
177
+
178
+ ```typescript
179
+ import { createHash } from 'ideal-auth';
180
+
181
+ const hash = createHash({ rounds: 12 });
182
+
183
+ const hashed = await hash.make('password');
184
+ const valid = await hash.verify('password', hashed); // true
185
+ ```
186
+
187
+ ---
188
+
189
+ ### Crypto Utilities
190
+
191
+ Standalone functions for tokens, signing, and encryption. No framework dependencies — uses `node:crypto`.
192
+
193
+ #### `generateToken(bytes?)`
194
+
195
+ Generate a cryptographically secure random hex string.
196
+
197
+ ```typescript
198
+ import { generateToken } from 'ideal-auth';
199
+
200
+ const token = generateToken(); // 64 hex chars (32 bytes)
201
+ const short = generateToken(16); // 32 hex chars (16 bytes)
202
+ ```
203
+
204
+ #### `signData(data, secret)` / `verifySignature(data, signature, secret)`
205
+
206
+ HMAC-SHA256 signing with timing-safe comparison.
207
+
208
+ ```typescript
209
+ import { signData, verifySignature } from 'ideal-auth';
210
+
211
+ const sig = signData('user:123:reset', secret);
212
+ const valid = verifySignature('user:123:reset', sig, secret); // true
213
+ ```
214
+
215
+ #### `encrypt(plaintext, secret)` / `decrypt(ciphertext, secret)`
216
+
217
+ AES-256-GCM encryption with scrypt key derivation. Output is base64url-encoded.
218
+
219
+ ```typescript
220
+ import { encrypt, decrypt } from 'ideal-auth';
221
+
222
+ const encrypted = await encrypt('sensitive data', secret);
223
+ const decrypted = await decrypt(encrypted, secret); // 'sensitive data'
224
+ ```
225
+
226
+ #### `timingSafeEqual(a, b)`
227
+
228
+ Constant-time string comparison to prevent timing attacks.
229
+
230
+ ```typescript
231
+ import { timingSafeEqual } from 'ideal-auth';
232
+
233
+ timingSafeEqual('abc', 'abc'); // true
234
+ timingSafeEqual('abc', 'xyz'); // false
235
+ ```
236
+
237
+ ---
238
+
239
+ ### `createTokenVerifier(config)`
240
+
241
+ Signed, expiring tokens for password resets, email verification, magic links, invites — any flow that needs a one-time, time-limited token tied to a user. Create one instance per use case with its own secret/expiry. You handle delivery (email, SMS) — ideal-auth handles the token lifecycle.
242
+
243
+ #### Config
244
+
245
+ | Option | Type | Default | Description |
246
+ | --- | --- | --- | --- |
247
+ | `secret` | `string` | — | Secret for HMAC signing (required) |
248
+ | `expiryMs` | `number` | `3600000` (1 hour) | Token lifetime in milliseconds |
249
+
250
+ #### Password Reset
251
+
252
+ ```typescript
253
+ import { createTokenVerifier, createHash } from 'ideal-auth';
254
+
255
+ const passwordReset = createTokenVerifier({
256
+ secret: process.env.IDEAL_AUTH_SECRET! + '-reset',
257
+ expiryMs: 60 * 60 * 1000, // 1 hour
258
+ });
259
+
260
+ // Forgot password — generate token, send it via email (POST body, not URL query)
261
+ const token = passwordReset.createToken(user.id);
262
+ await sendEmail(user.email, `https://example.com/reset/${token}`);
263
+
264
+ // Reset password — verify token
265
+ const result = passwordReset.verifyToken(token);
266
+ if (!result) throw new Error('Invalid or expired token');
267
+
268
+ // IMPORTANT: Invalidate the token by checking iatMs against the user's last
269
+ // password change. Tokens are stateless — without this check, a token remains
270
+ // valid until expiry even after the password is changed.
271
+ if (result.iatMs < user.passwordChangedAt) throw new Error('Token already used');
272
+
273
+ // result.userId is now available — update the password
274
+ const hash = createHash();
275
+ await db.user.update({
276
+ where: { id: result.userId },
277
+ data: { password: await hash.make(newPassword), passwordChangedAt: Date.now() },
278
+ });
279
+ ```
280
+
281
+ #### Email Verification
282
+
283
+ ```typescript
284
+ import { createTokenVerifier } from 'ideal-auth';
285
+
286
+ const emailVerification = createTokenVerifier({
287
+ secret: process.env.IDEAL_AUTH_SECRET! + '-email',
288
+ expiryMs: 24 * 60 * 60 * 1000, // 24 hours
289
+ });
290
+
291
+ // After registration — generate token, send verification email
292
+ const token = emailVerification.createToken(user.id);
293
+ await sendEmail(user.email, `https://example.com/verify/${token}`);
294
+
295
+ // Verify — validate token from the URL
296
+ const result = emailVerification.verifyToken(token);
297
+ if (!result) throw new Error('Invalid or expired token');
298
+
299
+ // Mark user as verified
300
+ await db.user.update({
301
+ where: { id: result.userId },
302
+ data: { emailVerifiedAt: new Date() },
303
+ });
304
+ ```
305
+
306
+ Use different secrets (or suffixes) per use case so tokens aren't interchangeable between flows.
307
+
308
+ **Token invalidation:** Tokens are stateless and valid until expiry. `verifyToken()` returns `iatMs` (issued-at timestamp in milliseconds) so you can reject tokens issued before a relevant event (e.g. password change, email already verified). You must implement this check — the library does not track token usage.
309
+
310
+ ---
311
+
312
+ ### Two-Factor Authentication (TOTP)
313
+
314
+ `createTOTP()` provides TOTP (RFC 6238) generation and verification — no framework dependencies.
315
+
316
+ #### Setup Flow
317
+
318
+ ```typescript
319
+ import { createTOTP } from 'ideal-auth';
320
+
321
+ const totp = createTOTP();
322
+
323
+ // 1. Generate a secret for the user
324
+ const secret = totp.generateSecret();
325
+ // Store `secret` in your database (encrypted) for the user
326
+
327
+ // 2. Generate a QR code URI for the user to scan
328
+ const uri = totp.generateQrUri({
329
+ secret,
330
+ issuer: 'MyApp',
331
+ account: user.email,
332
+ });
333
+ // Render `uri` as a QR code (use any QR library)
334
+
335
+ // 3. Verify the first code to confirm setup
336
+ const valid = totp.verify(codeFromUser, secret);
337
+ if (valid) {
338
+ // Mark 2FA as enabled for the user
339
+ }
340
+ ```
341
+
342
+ #### Login Verification
343
+
344
+ ```typescript
345
+ const totp = createTOTP();
346
+
347
+ // After password login, prompt for TOTP code
348
+ const valid = totp.verify(codeFromUser, user.totpSecret);
349
+ if (!valid) {
350
+ throw new Error('Invalid 2FA code');
351
+ }
352
+ ```
353
+
354
+ **Replay protection:** A valid TOTP code can be verified multiple times within the acceptance window (default 90 seconds). For mission-critical apps, store the last used time step per user and reject codes at or before that step.
355
+
356
+ #### Config
357
+
358
+ | Option | Type | Default | Description |
359
+ | --- | --- | --- | --- |
360
+ | `digits` | `number` | `6` | Number of digits in the code |
361
+ | `period` | `number` | `30` | Time step in seconds |
362
+ | `window` | `number` | `1` | Window of ±N steps to account for clock drift |
363
+
364
+ #### Recovery Codes
365
+
366
+ Generate backup codes for users who lose access to their authenticator app.
367
+
368
+ ```typescript
369
+ import { generateRecoveryCodes, verifyRecoveryCode, createHash } from 'ideal-auth';
370
+
371
+ const hash = createHash();
372
+
373
+ // Generate codes — returns plain codes to show the user AND hashed codes to store
374
+ const { codes, hashed } = await generateRecoveryCodes(hash);
375
+ // Show `codes` to the user once, store `hashed` in the database
376
+
377
+ // Verify a recovery code during login
378
+ const { valid, remaining } = await verifyRecoveryCode(code, user.hashedRecoveryCodes, hash);
379
+ if (valid) {
380
+ // Update stored hashes to `remaining` (removes the used code)
381
+ await db.user.update({ where: { id: user.id }, data: { recoveryCodes: remaining } });
382
+ }
383
+ ```
384
+
385
+ ---
386
+
387
+ ### Rate Limiting
388
+
389
+ In-memory rate limiter. Provide a custom `RateLimitStore` for Redis/DB-backed limiting.
390
+
391
+ ```typescript
392
+ import { createRateLimiter } from 'ideal-auth';
393
+
394
+ const limiter = createRateLimiter({
395
+ maxAttempts: 5,
396
+ windowMs: 60_000, // 1 minute
397
+ });
398
+
399
+ const result = await limiter.attempt('login:user@example.com');
400
+ // { allowed: true, remaining: 4, resetAt: Date }
401
+
402
+ // Reset after successful login
403
+ await limiter.reset('login:user@example.com');
404
+ ```
405
+
406
+ #### Full Login Action Example (Next.js)
407
+
408
+ ```typescript
409
+ 'use server';
410
+
411
+ import { redirect } from 'next/navigation';
412
+ import { headers } from 'next/headers';
413
+ import { auth } from '@/lib/auth';
414
+ import { createRateLimiter } from 'ideal-auth';
415
+
416
+ const limiter = createRateLimiter({
417
+ maxAttempts: 5,
418
+ windowMs: 60_000,
419
+ });
420
+
421
+ export async function loginAction(formData: FormData) {
422
+ const email = formData.get('email') as string;
423
+ const password = formData.get('password') as string;
424
+
425
+ // NOTE: x-forwarded-for is only trustworthy behind a reverse proxy you
426
+ // control (e.g. Vercel, Cloudflare, nginx). Without one, it's spoofable.
427
+ const headerStore = await headers();
428
+ const ip = headerStore.get('x-forwarded-for') ?? '127.0.0.1';
429
+ const key = `login:${ip}`;
430
+
431
+ const { allowed, remaining, resetAt } = await limiter.attempt(key);
432
+
433
+ if (!allowed) {
434
+ const seconds = Math.ceil((resetAt.getTime() - Date.now()) / 1000);
435
+ redirect(`/?error=rate_limit&retry=${seconds}`);
436
+ }
437
+
438
+ const session = auth();
439
+ const success = await session.attempt({ email, password });
440
+
441
+ if (!success) {
442
+ redirect(`/?error=invalid&remaining=${remaining}`);
443
+ }
444
+
445
+ await limiter.reset(key);
446
+ redirect('/');
447
+ }
448
+ ```
449
+
450
+ #### Custom Store
451
+
452
+ Implement the `RateLimitStore` interface for persistent storage:
453
+
454
+ ```typescript
455
+ import { createRateLimiter, type RateLimitStore } from 'ideal-auth';
456
+
457
+ class RedisRateLimitStore implements RateLimitStore {
458
+ async increment(key: string, windowMs: number) {
459
+ // Redis INCR + PEXPIRE logic
460
+ return { count, resetAt };
461
+ }
462
+ async reset(key: string) {
463
+ // Redis DEL
464
+ }
465
+ }
466
+
467
+ const limiter = createRateLimiter({
468
+ maxAttempts: 5,
469
+ windowMs: 60_000,
470
+ store: new RedisRateLimitStore(),
471
+ });
472
+ ```
473
+
474
+ ---
475
+
476
+ ## How It Works
477
+
478
+ Sessions are **stateless, encrypted cookies** powered by iron-session (AES-256-GCM + HMAC integrity).
479
+
480
+ 1. **`login(user)`** — Creates a `SessionPayload { uid, iat, exp }`, seals it with iron-session, writes the encrypted string to the cookie via the bridge.
481
+ 2. **`check()` / `user()` / `id()`** — Reads the cookie via the bridge, unseals the payload, checks expiry. `user()` additionally calls `resolveUser(id)` to fetch the full user.
482
+ 3. **`logout()`** — Deletes the cookie via the bridge.
483
+
484
+ No session IDs. No server-side storage. The encrypted cookie *is* the session.
485
+
486
+ ---
487
+
488
+ ## Cookie Bridge
489
+
490
+ The bridge decouples ideal-auth from any framework. Three functions:
491
+
492
+ ```typescript
493
+ interface CookieBridge {
494
+ get(name: string): Promise<string | undefined> | string | undefined;
495
+ set(name: string, value: string, options: CookieOptions): Promise<void> | void;
496
+ delete(name: string): Promise<void> | void;
497
+ }
498
+ ```
499
+
500
+ **Next.js** (App Router):
501
+
502
+ ```typescript
503
+ import { cookies } from 'next/headers';
504
+
505
+ cookie: {
506
+ get: async (name) => (await cookies()).get(name)?.value,
507
+ set: async (name, value, opts) => (await cookies()).set(name, value, opts),
508
+ delete: async (name) => (await cookies()).delete(name),
509
+ }
510
+ ```
511
+
512
+ **Express / Hono / any framework** — adapt to your framework's cookie API.
513
+
514
+ ---
515
+
516
+ ## Dependencies
517
+
518
+ | Package | Purpose |
519
+ | --- | --- |
520
+ | `iron-session` | Session sealing/unsealing (AES-256-GCM + HMAC) |
521
+ | `bcryptjs` | Password hashing |
522
+
523
+ Zero framework imports. Works in Node, Bun, Deno, and edge runtimes.
524
+
525
+ ## Support
526
+
527
+ If this saved you time, consider supporting the project:
528
+
529
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow?logo=buy-me-a-coffee&logoColor=white)](https://buymeacoffee.com/ramonmalcolm)
530
+
531
+ ## License
532
+
533
+ [MIT](./LICENSE.md)
@@ -0,0 +1,17 @@
1
+ import type { AnyUser, AuthInstance, ConfigurableCookieOptions, CookieBridge, HashInstance } from './types';
2
+ interface AuthInstanceDeps<TUser extends AnyUser> {
3
+ secret: string;
4
+ cookie: CookieBridge;
5
+ cookieName: string;
6
+ maxAge: number;
7
+ rememberMaxAge: number;
8
+ cookieOptions: ConfigurableCookieOptions;
9
+ resolveUser: (id: string) => Promise<TUser | null>;
10
+ hash?: HashInstance;
11
+ resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
12
+ credentialKey: string;
13
+ passwordField: string;
14
+ attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null>;
15
+ }
16
+ export declare function createAuthInstance<TUser extends AnyUser>(deps: AuthInstanceDeps<TUser>): AuthInstance<TUser>;
17
+ export {};
@@ -0,0 +1,92 @@
1
+ import { seal, unseal } from './session/seal';
2
+ import { buildCookieOptions } from './session/cookie';
3
+ export function createAuthInstance(deps) {
4
+ let cachedPayload;
5
+ let cachedUser;
6
+ async function readSession() {
7
+ if (cachedPayload !== undefined)
8
+ return cachedPayload;
9
+ const raw = await deps.cookie.get(deps.cookieName);
10
+ if (!raw) {
11
+ cachedPayload = null;
12
+ return null;
13
+ }
14
+ cachedPayload = await unseal(raw, deps.secret);
15
+ return cachedPayload;
16
+ }
17
+ async function writeSession(user, options) {
18
+ const maxAge = options?.remember ? deps.rememberMaxAge : deps.maxAge;
19
+ const now = Math.floor(Date.now() / 1000);
20
+ const payload = {
21
+ uid: String(user.id),
22
+ iat: now,
23
+ exp: now + maxAge,
24
+ };
25
+ const sealed = await seal(payload, deps.secret);
26
+ const opts = options?.remember === false
27
+ ? buildCookieOptions(undefined, deps.cookieOptions)
28
+ : buildCookieOptions(maxAge, deps.cookieOptions);
29
+ await deps.cookie.set(deps.cookieName, sealed, opts);
30
+ cachedPayload = payload;
31
+ cachedUser = user;
32
+ }
33
+ return {
34
+ async login(user, options) {
35
+ await writeSession(user, options);
36
+ },
37
+ async loginById(id, options) {
38
+ const user = await deps.resolveUser(id);
39
+ if (!user)
40
+ throw new Error('Login failed');
41
+ await writeSession(user, options);
42
+ },
43
+ async attempt(credentials, options) {
44
+ // Escape hatch: attemptUser handles everything
45
+ if (deps.attemptUser) {
46
+ const user = await deps.attemptUser(credentials);
47
+ if (!user)
48
+ return false;
49
+ await writeSession(user, options);
50
+ return true;
51
+ }
52
+ // Laravel-style: strip password, resolve user, verify hash
53
+ if (deps.hash && deps.resolveUserByCredentials) {
54
+ const { [deps.credentialKey]: password, ...lookup } = credentials;
55
+ const user = await deps.resolveUserByCredentials(lookup);
56
+ if (!user)
57
+ return false;
58
+ const storedHash = user[deps.passwordField];
59
+ if (!storedHash || !(await deps.hash.verify(password, storedHash))) {
60
+ return false;
61
+ }
62
+ await writeSession(user, options);
63
+ return true;
64
+ }
65
+ throw new Error('Provide either attemptUser() or both hash + resolveUserByCredentials in config to use attempt()');
66
+ },
67
+ async logout() {
68
+ await deps.cookie.delete(deps.cookieName);
69
+ cachedPayload = null;
70
+ cachedUser = null;
71
+ },
72
+ async check() {
73
+ const session = await readSession();
74
+ return session !== null;
75
+ },
76
+ async user() {
77
+ if (cachedUser !== undefined)
78
+ return cachedUser;
79
+ const session = await readSession();
80
+ if (!session) {
81
+ cachedUser = null;
82
+ return null;
83
+ }
84
+ cachedUser = await deps.resolveUser(session.uid);
85
+ return cachedUser;
86
+ },
87
+ async id() {
88
+ const session = await readSession();
89
+ return session?.uid ?? null;
90
+ },
91
+ };
92
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { AnyUser, AuthInstance, AuthConfig } from './types';
2
+ export declare function createAuth<TUser extends AnyUser = AnyUser>(config: AuthConfig<TUser>): () => AuthInstance<TUser>;
package/dist/auth.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createAuthInstance } from './auth-instance';
2
+ const SESSION_DEFAULTS = {
3
+ cookieName: 'ideal_session',
4
+ maxAge: 60 * 60 * 24 * 7, // 7 days
5
+ rememberMaxAge: 60 * 60 * 24 * 30, // 30 days
6
+ };
7
+ export function createAuth(config) {
8
+ if (!config.secret || config.secret.length < 32) {
9
+ throw new Error('secret must be at least 32 characters');
10
+ }
11
+ return () => createAuthInstance({
12
+ secret: config.secret,
13
+ cookie: config.cookie,
14
+ cookieName: config.session?.cookieName ?? SESSION_DEFAULTS.cookieName,
15
+ maxAge: config.session?.maxAge ?? SESSION_DEFAULTS.maxAge,
16
+ rememberMaxAge: config.session?.rememberMaxAge ?? SESSION_DEFAULTS.rememberMaxAge,
17
+ cookieOptions: config.session?.cookie ?? {},
18
+ resolveUser: config.resolveUser,
19
+ hash: config.hash,
20
+ resolveUserByCredentials: config.resolveUserByCredentials,
21
+ credentialKey: config.credentialKey ?? 'password',
22
+ passwordField: config.passwordField ?? 'password',
23
+ attemptUser: config.attemptUser,
24
+ });
25
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};