ideal-auth 0.3.0 → 0.5.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/README.md CHANGED
@@ -12,17 +12,19 @@ Provide a cookie bridge (3 functions) once during setup, and `auth().login(user)
12
12
  bun add ideal-auth
13
13
  ```
14
14
 
15
- ## Generate Secret
15
+ ## Generate Secrets
16
16
 
17
17
  ```bash
18
+ # Session secret (required — used by createAuth)
18
19
  bunx ideal-auth secret
19
- ```
20
+ # IDEAL_AUTH_SECRET=aLThikMgJKMBB5WZLE-lCaOQUdgPWU8BHRv99bkYaVY
20
21
 
21
- ```
22
- IDEAL_AUTH_SECRET=aLThikMgJKMBB5WZLE-lCaOQUdgPWU8BHRv99bkYaVY
22
+ # Encryption key (optional — used by encrypt/decrypt for data at rest)
23
+ bunx ideal-auth encryption-key
24
+ # ENCRYPTION_KEY=9546dd9fa461ce15f0aacd6e1b461b52
23
25
  ```
24
26
 
25
- Copy the output into your `.env` file. The secret must be at least 32 characters.
27
+ Copy the output into your `.env` file. `IDEAL_AUTH_SECRET` must be at least 32 characters. `ENCRYPTION_KEY` is only needed if you use `encrypt()`/`decrypt()` (e.g., encrypting TOTP secrets or access tokens at rest).
26
28
 
27
29
  ## Quick Start
28
30
 
@@ -94,12 +96,13 @@ Returns a function `auth()` that creates an `AuthInstance` on each call.
94
96
  | --- | --- | --- | --- |
95
97
  | `secret` | `string` | Yes | — |
96
98
  | `cookie` | `CookieBridge` | Yes | — |
97
- | `resolveUser` | `(id: string) => Promise<User \| null>` | Yes | — |
99
+ | `resolveUser` | `(id: string) => Promise<User \| null \| undefined>` | Yes (unless `sessionFields` is provided) | — |
100
+ | `sessionFields` | `(keyof User & string)[]` | Yes (unless `resolveUser` is provided) | — |
98
101
  | `hash` | `HashInstance` | No | — |
99
- | `resolveUserByCredentials` | `(creds: Record<string, any>) => Promise<User \| null>` | No | — |
102
+ | `resolveUserByCredentials` | `(creds: Record<string, any>) => Promise<User \| null \| undefined>` | No | — |
100
103
  | `credentialKey` | `string` | No | `'password'` |
101
104
  | `passwordField` | `string` | No | `'password'` |
102
- | `attemptUser` | `(creds: Record<string, any>) => Promise<User \| null>` | No | — |
105
+ | `attemptUser` | `(creds: Record<string, any>) => Promise<User \| null \| undefined>` | No | — |
103
106
  | `session.cookieName` | `string` | No | `'ideal_session'` |
104
107
  | `session.maxAge` | `number` (seconds) | No | `604800` (7 days) |
105
108
  | `session.rememberMaxAge` | `number` (seconds) | No | `2592000` (30 days) |
@@ -171,7 +174,11 @@ const auth = createAuth({
171
174
 
172
175
  ### `createHash(config?)`
173
176
 
174
- Returns a `HashInstance` using bcrypt.
177
+ Returns a `HashInstance` using bcrypt. Requires `bcryptjs` (optional peer dependency):
178
+
179
+ ```bash
180
+ bun add bcryptjs
181
+ ```
175
182
 
176
183
  | Option | Type | Default |
177
184
  | --- | --- | --- |
@@ -186,6 +193,27 @@ const hashed = await hash.make('password');
186
193
  const valid = await hash.verify('password', hashed); // true
187
194
  ```
188
195
 
196
+ ### Custom hash (bring your own)
197
+
198
+ Skip `bcryptjs` entirely by providing your own `HashInstance`:
199
+
200
+ ```typescript
201
+ import { prehash } from 'ideal-auth';
202
+ import type { HashInstance } from 'ideal-auth';
203
+
204
+ // Bun native bcrypt (use prehash to prevent silent truncation at 72 bytes)
205
+ const hash: HashInstance = {
206
+ make: (password) => Bun.password.hash(prehash(password), { algorithm: 'bcrypt', cost: 12 }),
207
+ verify: (password, hash) => Bun.password.verify(prehash(password), hash),
208
+ };
209
+
210
+ // Bun argon2id (OWASP recommended — no prehash needed, no input length limit)
211
+ const hash: HashInstance = {
212
+ make: (password) => Bun.password.hash(password, { algorithm: 'argon2id' }),
213
+ verify: (password, hash) => Bun.password.verify(password, hash),
214
+ };
215
+ ```
216
+
189
217
  ---
190
218
 
191
219
  ### Crypto Utilities
@@ -517,10 +545,10 @@ cookie: {
517
545
 
518
546
  ## Dependencies
519
547
 
520
- | Package | Purpose |
521
- | --- | --- |
522
- | `iron-session` | Session sealing/unsealing (AES-256-CBC + HMAC) |
523
- | `bcryptjs` | Password hashing |
548
+ | Package | Purpose | Required |
549
+ | --- | --- | --- |
550
+ | `iron-session` | Session sealing/unsealing (AES-256-CBC + HMAC) | Yes |
551
+ | `bcryptjs` | Password hashing (used by `createHash()`) | Optional — not needed if you provide your own `HashInstance` |
524
552
 
525
553
  Zero framework imports. Works in Node, Bun, Deno, and edge runtimes.
526
554
 
@@ -6,13 +6,13 @@ interface AuthInstanceDeps<TUser extends AnyUser> {
6
6
  maxAge: number;
7
7
  rememberMaxAge: number;
8
8
  cookieOptions: ConfigurableCookieOptions;
9
- resolveUser?: (id: string) => Promise<TUser | null>;
9
+ resolveUser?: (id: string) => Promise<TUser | null | undefined>;
10
10
  sessionFields?: (keyof TUser & string)[];
11
11
  hash?: HashInstance;
12
- resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
12
+ resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
13
13
  credentialKey: string;
14
14
  passwordField: string;
15
- attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null>;
15
+ attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
16
16
  }
17
17
  export declare function createAuthInstance<TUser extends AnyUser>(deps: AuthInstanceDeps<TUser>): AuthInstance<TUser>;
18
18
  export {};
@@ -106,7 +106,7 @@ export function createAuthInstance(deps) {
106
106
  }
107
107
  // Database-backed: resolve user via callback
108
108
  if (deps.resolveUser) {
109
- cachedUser = await deps.resolveUser(session.uid);
109
+ cachedUser = (await deps.resolveUser(session.uid)) ?? null;
110
110
  return cachedUser;
111
111
  }
112
112
  cachedUser = null;
@@ -1,2 +1,12 @@
1
1
  import type { HashInstance, HashConfig } from '../types';
2
+ /**
3
+ * SHA-256 prehash for bcrypt's 72-byte input limit.
4
+ * Passwords exceeding 72 UTF-8 bytes are hashed to a 44-char base64 string
5
+ * before being passed to bcrypt, preventing silent truncation.
6
+ *
7
+ * Only needed for bcrypt — argon2 has no input length limit.
8
+ * Applied automatically by `createHash()`. Use this when building
9
+ * a custom bcrypt `HashInstance` (e.g., with `Bun.password`).
10
+ */
11
+ export declare function prehash(password: string): string;
2
12
  export declare function createHash(config?: HashConfig): HashInstance;
@@ -1,23 +1,49 @@
1
1
  import { createHash as nodeCryptoHash } from 'node:crypto';
2
- import bcrypt from 'bcryptjs';
3
2
  const DEFAULT_ROUNDS = 12;
4
3
  const BCRYPT_MAX_BYTES = 72;
5
- function prehash(password) {
4
+ /**
5
+ * SHA-256 prehash for bcrypt's 72-byte input limit.
6
+ * Passwords exceeding 72 UTF-8 bytes are hashed to a 44-char base64 string
7
+ * before being passed to bcrypt, preventing silent truncation.
8
+ *
9
+ * Only needed for bcrypt — argon2 has no input length limit.
10
+ * Applied automatically by `createHash()`. Use this when building
11
+ * a custom bcrypt `HashInstance` (e.g., with `Bun.password`).
12
+ */
13
+ export function prehash(password) {
14
+ if (Buffer.byteLength(password, 'utf8') <= BCRYPT_MAX_BYTES)
15
+ return password;
6
16
  return nodeCryptoHash('sha256').update(password).digest('base64');
7
17
  }
18
+ async function loadBcrypt() {
19
+ try {
20
+ return await import('bcryptjs');
21
+ }
22
+ catch {
23
+ throw new Error('bcryptjs is required for createHash(). Install it as a dependency in your project.\n' +
24
+ 'Alternatively, provide your own HashInstance (e.g., using Bun.password or argon2).');
25
+ }
26
+ }
8
27
  export function createHash(config) {
9
28
  const rounds = config?.rounds ?? DEFAULT_ROUNDS;
29
+ let bcryptModule = null;
30
+ async function getBcrypt() {
31
+ if (!bcryptModule) {
32
+ bcryptModule = await loadBcrypt();
33
+ }
34
+ return bcryptModule;
35
+ }
10
36
  return {
11
37
  async make(password) {
12
38
  if (!password)
13
39
  throw new Error('password must not be empty');
14
- const input = Buffer.byteLength(password, 'utf8') > BCRYPT_MAX_BYTES ? prehash(password) : password;
40
+ const bcrypt = await getBcrypt();
15
41
  const salt = await bcrypt.genSalt(rounds);
16
- return bcrypt.hash(input, salt);
42
+ return bcrypt.hash(prehash(password), salt);
17
43
  },
18
44
  async verify(password, hash) {
19
- const input = Buffer.byteLength(password, 'utf8') > BCRYPT_MAX_BYTES ? prehash(password) : password;
20
- return bcrypt.compare(input, hash);
45
+ const bcrypt = await getBcrypt();
46
+ return bcrypt.compare(prehash(password), hash);
21
47
  },
22
48
  };
23
49
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createAuth } from './auth';
2
- export { createHash } from './hash';
2
+ export { createHash, prehash } from './hash';
3
3
  export { generateToken } from './crypto/token';
4
4
  export { signData, verifySignature } from './crypto/hmac';
5
5
  export { encrypt, decrypt } from './crypto/encryption';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Auth
2
2
  export { createAuth } from './auth';
3
3
  // Hash
4
- export { createHash } from './hash';
4
+ export { createHash, prehash } from './hash';
5
5
  // Crypto utilities
6
6
  export { generateToken } from './crypto/token';
7
7
  export { signData, verifySignature } from './crypto/hmac';
package/dist/types.d.ts CHANGED
@@ -27,7 +27,7 @@ export interface SessionPayload {
27
27
  export interface LoginOptions {
28
28
  remember?: boolean;
29
29
  }
30
- export interface AuthConfig<TUser extends AnyUser = AnyUser> {
30
+ interface AuthConfigBase<TUser extends AnyUser> {
31
31
  secret: string;
32
32
  cookie: CookieBridge;
33
33
  session?: {
@@ -36,33 +36,39 @@ export interface AuthConfig<TUser extends AnyUser = AnyUser> {
36
36
  rememberMaxAge?: number;
37
37
  cookie?: Partial<ConfigurableCookieOptions>;
38
38
  };
39
- resolveUser?: (id: string) => Promise<TUser | null>;
40
- /**
41
- * Fields from the user object to store in the session cookie.
42
- * When provided, `resolveUser` is not needed — `user()` returns
43
- * the stored fields directly from the cookie.
44
- *
45
- * The `id` field is always stored. List only additional fields.
46
- * Keep the total small — session cookies have a ~4KB size limit.
47
- *
48
- * Cannot be used together with `resolveUser`.
49
- *
50
- * **Staleness:** Data is snapshotted at login time. If a user's role
51
- * or permissions change server-side, the cookie retains the old values
52
- * until the user re-logs in. For authorization-critical fields (role,
53
- * permissions, subscription tier), prefer `resolveUser` to get fresh
54
- * data on every request.
55
- *
56
- * **ID type:** `user()` always returns `id` as a `string` on subsequent
57
- * requests (read from cookie), even if the original `TUser.id` was a number.
58
- */
59
- sessionFields?: (keyof TUser & string)[];
60
39
  hash?: HashInstance;
61
- resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
40
+ resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
62
41
  credentialKey?: string;
63
42
  passwordField?: string;
64
- attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null>;
65
- }
43
+ attemptUser?: (credentials: Record<string, any>) => Promise<TUser | null | undefined>;
44
+ }
45
+ /** Database-backed: `user()` calls `resolveUser(id)` on every request. */
46
+ interface AuthConfigWithResolveUser<TUser extends AnyUser> extends AuthConfigBase<TUser> {
47
+ resolveUser: (id: string) => Promise<TUser | null | undefined>;
48
+ /** Cannot use `sessionFields` together with `resolveUser`. */
49
+ sessionFields?: never;
50
+ }
51
+ /**
52
+ * Cookie-backed: `user()` reads declared fields from the session cookie.
53
+ *
54
+ * The `id` field is always stored. List only additional fields.
55
+ * Keep the total small — session cookies have a ~4KB size limit.
56
+ *
57
+ * **Staleness:** Data is snapshotted at login time. If a user's role
58
+ * or permissions change server-side, the cookie retains the old values
59
+ * until the user re-logs in. For authorization-critical fields (role,
60
+ * permissions, subscription tier), prefer `resolveUser` to get fresh
61
+ * data on every request.
62
+ *
63
+ * **ID type:** `user()` always returns `id` as a `string` on subsequent
64
+ * requests (read from cookie), even if the original `TUser.id` was a number.
65
+ */
66
+ interface AuthConfigWithSessionFields<TUser extends AnyUser> extends AuthConfigBase<TUser> {
67
+ /** Cannot use `resolveUser` together with `sessionFields`. */
68
+ resolveUser?: never;
69
+ sessionFields: (keyof TUser & string)[];
70
+ }
71
+ export type AuthConfig<TUser extends AnyUser = AnyUser> = AuthConfigWithResolveUser<TUser> | AuthConfigWithSessionFields<TUser>;
66
72
  export interface HashConfig {
67
73
  rounds?: number;
68
74
  }
@@ -125,3 +131,4 @@ export interface RecoveryCodeResult {
125
131
  valid: boolean;
126
132
  remaining: string[];
127
133
  }
134
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ideal-auth",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Auth primitives for the JS ecosystem. Zero framework dependencies.",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -25,17 +25,20 @@
25
25
  "skills"
26
26
  ],
27
27
  "dependencies": {
28
- "iron-session": "^8.0.4",
29
- "bcryptjs": "^3.0.3"
28
+ "iron-session": "^8.0.4"
30
29
  },
31
30
  "devDependencies": {
32
31
  "@types/bcryptjs": "^3.0.0",
33
32
  "@types/node": "^20"
34
33
  },
35
34
  "peerDependencies": {
35
+ "bcryptjs": "^3.0.3",
36
36
  "typescript": "^5.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
+ "bcryptjs": {
40
+ "optional": true
41
+ },
39
42
  "typescript": {
40
43
  "optional": true
41
44
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
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, passkey, passkeys, WebAuthn, passwordless, biometric, fingerprint.
4
4
  ---
5
5
 
6
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.
@@ -194,27 +194,67 @@ const auth = createAuth({
194
194
 
195
195
  ---
196
196
 
197
- ### `createHash(config?): HashInstance`
197
+ ### Password Hashing
198
198
 
199
- bcrypt password hashing with automatic SHA-256 prehash for passwords > 72 bytes.
199
+ Password hashing is **only needed for the Laravel-style `attempt()` flow** (`hash` + `resolveUserByCredentials`). If you use `attemptUser`, `login(user)` directly, or `sessionFields` without credential verification, no `HashInstance` or `bcryptjs` is required.
200
200
 
201
- ```typescript
202
- type HashConfig = { rounds?: number }; // default: 12
201
+ ideal-auth accepts any `HashInstance`:
203
202
 
203
+ ```typescript
204
204
  type HashInstance = {
205
205
  make(password: string): Promise<string>;
206
206
  verify(password: string, hash: string): Promise<boolean>;
207
207
  };
208
208
  ```
209
209
 
210
+ **`createHash()` (bcryptjs)** — built-in convenience. Requires `bcryptjs` as an optional peer dependency (`bun add bcryptjs`). Throws a clear error if not installed.
211
+
210
212
  ```typescript
211
213
  import { createHash } from 'ideal-auth';
212
214
 
213
- const hash = createHash({ rounds: 12 });
215
+ const hash = createHash({ rounds: 12 }); // default: 12
214
216
  const hashed = await hash.make('password');
215
217
  const valid = await hash.verify('password', hashed); // true
216
218
  ```
217
219
 
220
+ **Custom hash (bring your own)** — use your runtime's native hashing or a different algorithm. No `bcryptjs` needed.
221
+
222
+ ```typescript
223
+ import { prehash } from 'ideal-auth';
224
+
225
+ // Bun native bcrypt (faster than bcryptjs — use prehash for 72-byte limit)
226
+ const hash: HashInstance = {
227
+ make: (password) => Bun.password.hash(prehash(password), { algorithm: 'bcrypt', cost: 12 }),
228
+ verify: (password, hash) => Bun.password.verify(prehash(password), hash),
229
+ };
230
+
231
+ // Bun argon2id (OWASP recommended — no prehash needed, no input length limit)
232
+ const hash: HashInstance = {
233
+ make: (password) => Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 65536, timeCost: 2 }),
234
+ verify: (password, hash) => Bun.password.verify(password, hash),
235
+ };
236
+
237
+ // Node argon2 (requires: bun add argon2 — no prehash needed)
238
+ import argon2 from 'argon2';
239
+ const hash: HashInstance = {
240
+ make: (password) => argon2.hash(password),
241
+ verify: (password, hash) => argon2.verify(hash, password),
242
+ };
243
+ ```
244
+
245
+ Pass either to `createAuth`:
246
+
247
+ ```typescript
248
+ const auth = createAuth({
249
+ secret: process.env.IDEAL_AUTH_SECRET!,
250
+ cookie: createCookieBridge(),
251
+ resolveUser: async (id) => db.user.findUnique({ where: { id } }),
252
+ hash, // createHash() or your custom HashInstance
253
+ resolveUserByCredentials: async (creds) =>
254
+ db.user.findUnique({ where: { email: creds.email } }),
255
+ });
256
+ ```
257
+
218
258
  ---
219
259
 
220
260
  ### `createTokenVerifier(config): TokenVerifierInstance`
@@ -1582,6 +1622,53 @@ await auth().login(user, { remember: true });
1582
1622
 
1583
1623
  ---
1584
1624
 
1625
+ ## Passkeys (WebAuthn)
1626
+
1627
+ Passkeys use public-key cryptography for passwordless authentication. ideal-auth handles the session after verification — the WebAuthn protocol is handled by `@simplewebauthn/server` and `@simplewebauthn/browser`.
1628
+
1629
+ No `hash` or `bcryptjs` needed — there are no passwords.
1630
+
1631
+ ### Flow
1632
+
1633
+ ```
1634
+ Registration: browser creates key pair → store public key in DB
1635
+ Authentication: server sends challenge → browser signs it → server verifies → auth().login(user)
1636
+ ```
1637
+
1638
+ ### After WebAuthn verification, create session with ideal-auth
1639
+
1640
+ ```ts
1641
+ import { verifyAuthenticationResponse } from '@simplewebauthn/server';
1642
+ import { auth } from './auth';
1643
+
1644
+ const verification = await verifyAuthenticationResponse({
1645
+ response: body,
1646
+ expectedChallenge,
1647
+ expectedOrigin: origin,
1648
+ expectedRPID: rpID,
1649
+ credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter },
1650
+ });
1651
+
1652
+ if (verification.verified) {
1653
+ // Update signature counter (replay protection)
1654
+ await db.passkey.update({ where: { id: passkey.id }, data: { counter: Number(verification.authenticationInfo.newCounter) } });
1655
+
1656
+ const user = await db.user.findUnique({ where: { id: passkey.userId } });
1657
+ await auth().login(user); // session created — done
1658
+ }
1659
+ ```
1660
+
1661
+ ### Key points
1662
+ - Install: `bun add @simplewebauthn/server @simplewebauthn/browser`
1663
+ - Store challenges in httpOnly cookies (5-min expiry), not client-side
1664
+ - Always update the signature counter after authentication
1665
+ - Set `rpID` to root domain (e.g., `example.com`) — works across subdomains
1666
+ - `userVerification: 'preferred'` for most apps, `'required'` for high-security
1667
+ - Passkeys with user verification can replace 2FA (biometric/PIN is the second factor)
1668
+ - Rate limit the options and verify endpoints
1669
+
1670
+ ---
1671
+
1585
1672
  ## Open Redirect Prevention
1586
1673
 
1587
1674
  Always validate redirect URLs after login. Never redirect to user-supplied absolute URLs.