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 +41 -13
- package/dist/auth-instance.d.ts +3 -3
- package/dist/auth-instance.js +1 -1
- package/dist/hash/index.d.ts +10 -0
- package/dist/hash/index.js +32 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +32 -25
- package/package.json +6 -3
- package/skills/ideal-auth/SKILL.md +93 -6
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/dist/auth-instance.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/auth-instance.js
CHANGED
|
@@ -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;
|
package/dist/hash/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/hash/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
40
|
+
const bcrypt = await getBcrypt();
|
|
15
41
|
const salt = await bcrypt.genSalt(rounds);
|
|
16
|
-
return bcrypt.hash(
|
|
42
|
+
return bcrypt.hash(prehash(password), salt);
|
|
17
43
|
},
|
|
18
44
|
async verify(password, hash) {
|
|
19
|
-
const
|
|
20
|
-
return bcrypt.compare(
|
|
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
|
-
|
|
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
|
+
"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
|
-
###
|
|
197
|
+
### Password Hashing
|
|
198
198
|
|
|
199
|
-
|
|
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
|
-
|
|
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.
|