ideal-auth 0.2.0 → 0.4.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 +43 -6
- package/dist/auth-instance.d.ts +2 -1
- package/dist/auth-instance.js +30 -2
- package/dist/auth.js +10 -0
- 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/session/seal.js +6 -1
- package/dist/types.d.ts +22 -1
- package/package.json +9 -4
- package/skills/ideal-auth/SKILL.md +2040 -0
package/README.md
CHANGED
|
@@ -171,7 +171,11 @@ const auth = createAuth({
|
|
|
171
171
|
|
|
172
172
|
### `createHash(config?)`
|
|
173
173
|
|
|
174
|
-
Returns a `HashInstance` using bcrypt.
|
|
174
|
+
Returns a `HashInstance` using bcrypt. Requires `bcryptjs` (optional peer dependency):
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
bun add bcryptjs
|
|
178
|
+
```
|
|
175
179
|
|
|
176
180
|
| Option | Type | Default |
|
|
177
181
|
| --- | --- | --- |
|
|
@@ -186,6 +190,27 @@ const hashed = await hash.make('password');
|
|
|
186
190
|
const valid = await hash.verify('password', hashed); // true
|
|
187
191
|
```
|
|
188
192
|
|
|
193
|
+
### Custom hash (bring your own)
|
|
194
|
+
|
|
195
|
+
Skip `bcryptjs` entirely by providing your own `HashInstance`:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import { prehash } from 'ideal-auth';
|
|
199
|
+
import type { HashInstance } from 'ideal-auth';
|
|
200
|
+
|
|
201
|
+
// Bun native bcrypt (use prehash to prevent silent truncation at 72 bytes)
|
|
202
|
+
const hash: HashInstance = {
|
|
203
|
+
make: (password) => Bun.password.hash(prehash(password), { algorithm: 'bcrypt', cost: 12 }),
|
|
204
|
+
verify: (password, hash) => Bun.password.verify(prehash(password), hash),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Bun argon2id (OWASP recommended — no prehash needed, no input length limit)
|
|
208
|
+
const hash: HashInstance = {
|
|
209
|
+
make: (password) => Bun.password.hash(password, { algorithm: 'argon2id' }),
|
|
210
|
+
verify: (password, hash) => Bun.password.verify(password, hash),
|
|
211
|
+
};
|
|
212
|
+
```
|
|
213
|
+
|
|
189
214
|
---
|
|
190
215
|
|
|
191
216
|
### Crypto Utilities
|
|
@@ -477,7 +502,7 @@ const limiter = createRateLimiter({
|
|
|
477
502
|
|
|
478
503
|
## How It Works
|
|
479
504
|
|
|
480
|
-
Sessions are **stateless, encrypted cookies** powered by iron-session (AES-256-
|
|
505
|
+
Sessions are **stateless, encrypted cookies** powered by iron-session (AES-256-CBC + HMAC integrity).
|
|
481
506
|
|
|
482
507
|
1. **`login(user)`** — Creates a `SessionPayload { uid, iat, exp }`, seals it with iron-session, writes the encrypted string to the cookie via the bridge.
|
|
483
508
|
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.
|
|
@@ -517,13 +542,25 @@ cookie: {
|
|
|
517
542
|
|
|
518
543
|
## Dependencies
|
|
519
544
|
|
|
520
|
-
| Package | Purpose |
|
|
521
|
-
| --- | --- |
|
|
522
|
-
| `iron-session` | Session sealing/unsealing (AES-256-
|
|
523
|
-
| `bcryptjs` | Password hashing |
|
|
545
|
+
| Package | Purpose | Required |
|
|
546
|
+
| --- | --- | --- |
|
|
547
|
+
| `iron-session` | Session sealing/unsealing (AES-256-CBC + HMAC) | Yes |
|
|
548
|
+
| `bcryptjs` | Password hashing (used by `createHash()`) | Optional — not needed if you provide your own `HashInstance` |
|
|
524
549
|
|
|
525
550
|
Zero framework imports. Works in Node, Bun, Deno, and edge runtimes.
|
|
526
551
|
|
|
552
|
+
## Claude Code
|
|
553
|
+
|
|
554
|
+
If you use [Claude Code](https://claude.com/claude-code), install the ideal-auth plugin so your AI assistant knows the full API, cookie bridge patterns, security best practices, and implementation guides:
|
|
555
|
+
|
|
556
|
+
```bash
|
|
557
|
+
claude plugin install github:ramonmalcolm10/ideal-auth
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
After installing, Claude Code will automatically help with auth setup, login/registration flows, middleware, 2FA, password reset, rate limiting, and more — using the correct patterns for your framework.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
527
564
|
## Support
|
|
528
565
|
|
|
529
566
|
If this saved you time, consider supporting the project:
|
package/dist/auth-instance.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ interface AuthInstanceDeps<TUser extends AnyUser> {
|
|
|
6
6
|
maxAge: number;
|
|
7
7
|
rememberMaxAge: number;
|
|
8
8
|
cookieOptions: ConfigurableCookieOptions;
|
|
9
|
-
resolveUser
|
|
9
|
+
resolveUser?: (id: string) => Promise<TUser | null>;
|
|
10
|
+
sessionFields?: (keyof TUser & string)[];
|
|
10
11
|
hash?: HashInstance;
|
|
11
12
|
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
|
|
12
13
|
credentialKey: string;
|
package/dist/auth-instance.js
CHANGED
|
@@ -14,6 +14,17 @@ export function createAuthInstance(deps) {
|
|
|
14
14
|
cachedPayload = await unseal(raw, deps.secret);
|
|
15
15
|
return cachedPayload;
|
|
16
16
|
}
|
|
17
|
+
function pickSessionData(user) {
|
|
18
|
+
if (!deps.sessionFields)
|
|
19
|
+
return undefined;
|
|
20
|
+
const data = {};
|
|
21
|
+
for (const field of deps.sessionFields) {
|
|
22
|
+
if (field !== 'id' && field in user) {
|
|
23
|
+
data[field] = user[field];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(data).length > 0 ? data : undefined;
|
|
27
|
+
}
|
|
17
28
|
async function writeSession(user, options) {
|
|
18
29
|
const maxAge = options?.remember ? deps.rememberMaxAge : deps.maxAge;
|
|
19
30
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -21,6 +32,7 @@ export function createAuthInstance(deps) {
|
|
|
21
32
|
uid: String(user.id),
|
|
22
33
|
iat: now,
|
|
23
34
|
exp: now + maxAge,
|
|
35
|
+
data: pickSessionData(user),
|
|
24
36
|
};
|
|
25
37
|
const sealed = await seal(payload, deps.secret);
|
|
26
38
|
const opts = options?.remember === false
|
|
@@ -28,13 +40,19 @@ export function createAuthInstance(deps) {
|
|
|
28
40
|
: buildCookieOptions(maxAge, deps.cookieOptions);
|
|
29
41
|
await deps.cookie.set(deps.cookieName, sealed, opts);
|
|
30
42
|
cachedPayload = payload;
|
|
31
|
-
|
|
43
|
+
// When using sessionFields, only cache the picked fields (matching what's in the cookie)
|
|
44
|
+
cachedUser = payload.data
|
|
45
|
+
? { id: user.id, ...payload.data }
|
|
46
|
+
: user;
|
|
32
47
|
}
|
|
33
48
|
return {
|
|
34
49
|
async login(user, options) {
|
|
35
50
|
await writeSession(user, options);
|
|
36
51
|
},
|
|
37
52
|
async loginById(id, options) {
|
|
53
|
+
if (!deps.resolveUser) {
|
|
54
|
+
throw new Error('loginById requires resolveUser — use login(user) instead when using sessionFields');
|
|
55
|
+
}
|
|
38
56
|
const user = await deps.resolveUser(id);
|
|
39
57
|
if (!user)
|
|
40
58
|
throw new Error('Login failed');
|
|
@@ -81,7 +99,17 @@ export function createAuthInstance(deps) {
|
|
|
81
99
|
cachedUser = null;
|
|
82
100
|
return null;
|
|
83
101
|
}
|
|
84
|
-
|
|
102
|
+
// Cookie-backed: reconstruct user from session data
|
|
103
|
+
if (deps.sessionFields && session.data) {
|
|
104
|
+
cachedUser = { id: session.uid, ...session.data };
|
|
105
|
+
return cachedUser;
|
|
106
|
+
}
|
|
107
|
+
// Database-backed: resolve user via callback
|
|
108
|
+
if (deps.resolveUser) {
|
|
109
|
+
cachedUser = await deps.resolveUser(session.uid);
|
|
110
|
+
return cachedUser;
|
|
111
|
+
}
|
|
112
|
+
cachedUser = null;
|
|
85
113
|
return cachedUser;
|
|
86
114
|
},
|
|
87
115
|
async id() {
|
package/dist/auth.js
CHANGED
|
@@ -8,6 +8,15 @@ export function createAuth(config) {
|
|
|
8
8
|
if (!config.secret || config.secret.length < 32) {
|
|
9
9
|
throw new Error('secret must be at least 32 characters');
|
|
10
10
|
}
|
|
11
|
+
if (config.resolveUser && config.sessionFields) {
|
|
12
|
+
throw new Error('Provide either resolveUser or sessionFields, not both');
|
|
13
|
+
}
|
|
14
|
+
if (!config.resolveUser && !config.sessionFields) {
|
|
15
|
+
throw new Error('Provide either resolveUser or sessionFields');
|
|
16
|
+
}
|
|
17
|
+
if (config.sessionFields && config.sessionFields.filter((f) => f !== 'id').length === 0) {
|
|
18
|
+
throw new Error('sessionFields must contain at least one field besides id');
|
|
19
|
+
}
|
|
11
20
|
return () => createAuthInstance({
|
|
12
21
|
secret: config.secret,
|
|
13
22
|
cookie: config.cookie,
|
|
@@ -16,6 +25,7 @@ export function createAuth(config) {
|
|
|
16
25
|
rememberMaxAge: config.session?.rememberMaxAge ?? SESSION_DEFAULTS.rememberMaxAge,
|
|
17
26
|
cookieOptions: config.session?.cookie ?? {},
|
|
18
27
|
resolveUser: config.resolveUser,
|
|
28
|
+
sessionFields: config.sessionFields,
|
|
19
29
|
hash: config.hash,
|
|
20
30
|
resolveUserByCredentials: config.resolveUserByCredentials,
|
|
21
31
|
credentialKey: config.credentialKey ?? 'password',
|
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/session/seal.js
CHANGED
|
@@ -11,7 +11,12 @@ export async function unseal(sealed, secret) {
|
|
|
11
11
|
return null;
|
|
12
12
|
if (data.exp < Math.floor(Date.now() / 1000))
|
|
13
13
|
return null;
|
|
14
|
-
return
|
|
14
|
+
return {
|
|
15
|
+
uid: data.uid,
|
|
16
|
+
iat: data.iat,
|
|
17
|
+
exp: data.exp,
|
|
18
|
+
...(data.data !== undefined && { data: data.data }),
|
|
19
|
+
};
|
|
15
20
|
}
|
|
16
21
|
catch {
|
|
17
22
|
return null;
|
package/dist/types.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SessionPayload {
|
|
|
22
22
|
uid: string;
|
|
23
23
|
iat: number;
|
|
24
24
|
exp: number;
|
|
25
|
+
data?: Record<string, unknown>;
|
|
25
26
|
}
|
|
26
27
|
export interface LoginOptions {
|
|
27
28
|
remember?: boolean;
|
|
@@ -35,7 +36,27 @@ export interface AuthConfig<TUser extends AnyUser = AnyUser> {
|
|
|
35
36
|
rememberMaxAge?: number;
|
|
36
37
|
cookie?: Partial<ConfigurableCookieOptions>;
|
|
37
38
|
};
|
|
38
|
-
resolveUser
|
|
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)[];
|
|
39
60
|
hash?: HashInstance;
|
|
40
61
|
resolveUserByCredentials?: (credentials: Record<string, any>) => Promise<TUser | null>;
|
|
41
62
|
credentialKey?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ideal-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Auth primitives for the JS ecosystem. Zero framework dependencies.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "tsc",
|
|
@@ -20,20 +20,25 @@
|
|
|
20
20
|
"ideal-auth": "./dist/bin/ideal-auth.js"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"dist"
|
|
23
|
+
"dist",
|
|
24
|
+
".claude-plugin",
|
|
25
|
+
"skills"
|
|
24
26
|
],
|
|
25
27
|
"dependencies": {
|
|
26
|
-
"iron-session": "^8.0.4"
|
|
27
|
-
"bcryptjs": "^3.0.3"
|
|
28
|
+
"iron-session": "^8.0.4"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/bcryptjs": "^3.0.0",
|
|
31
32
|
"@types/node": "^20"
|
|
32
33
|
},
|
|
33
34
|
"peerDependencies": {
|
|
35
|
+
"bcryptjs": "^3.0.3",
|
|
34
36
|
"typescript": "^5.0.0"
|
|
35
37
|
},
|
|
36
38
|
"peerDependenciesMeta": {
|
|
39
|
+
"bcryptjs": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
37
42
|
"typescript": {
|
|
38
43
|
"optional": true
|
|
39
44
|
}
|