@luckystack/login 0.10.2 → 0.11.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/AGENTS.md +7 -0
- package/CHANGELOG.md +6 -1
- package/README.md +7 -235
- package/dist/accountStrategy.d.ts +12 -0
- package/dist/authLockout.d.ts +29 -0
- package/dist/data/commonPasswords.d.ts +2 -0
- package/dist/emailChange.d.ts +21 -0
- package/dist/emailChangeNotification.d.ts +24 -0
- package/dist/emailCodeLogin.d.ts +19 -0
- package/dist/emailModuleLoader.d.ts +13 -0
- package/dist/emailOtp.d.ts +33 -0
- package/dist/forgotPassword.d.ts +21 -0
- package/dist/hookPayloads.d.ts +151 -0
- package/dist/index.d.ts +33 -951
- package/dist/login.d.ts +96 -0
- package/dist/logout.d.ts +7 -0
- package/dist/oauthProviders.d.ts +171 -0
- package/dist/passwordPolicy.d.ts +8 -0
- package/dist/passwordReset.d.ts +48 -0
- package/dist/redirectResolver.d.ts +19 -0
- package/dist/register.d.ts +1 -2
- package/dist/session.d.ts +73 -0
- package/dist/sessionAdapter.d.ts +66 -0
- package/dist/sessionLayout.d.ts +1 -0
- package/dist/sessionSanitizer.d.ts +19 -0
- package/dist/totp.d.ts +38 -0
- package/dist/twoFactor.d.ts +60 -0
- package/dist/userAdapter.d.ts +68 -0
- package/docs/credentials-auth.md +3 -217
- package/docs/hooks.md +3 -442
- package/docs/logout-flow.md +3 -233
- package/docs/oauth-providers.md +2 -437
- package/docs/password-reset.md +2 -332
- package/docs/redirect-validation.md +4 -389
- package/docs/session-management.md +3 -282
- package/docs/user-adapter.md +2 -350
- package/package.json +4 -4
- package/CLAUDE.md +0 -197
package/docs/password-reset.md
CHANGED
|
@@ -1,335 +1,5 @@
|
|
|
1
1
|
# Password reset
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-managed email and consumer-managed UI are separate choices so a project need not install mail infrastructure it does not use. Reset and email-change credentials are short-lived, single-use and hashed at rest: a Redis snapshot should not contain a redeemable link. They do not share the pluggable session-store adapter. See `../src/forgotPassword.ts`, `../src/passwordReset.ts`, `../../core/src/oneTimeToken.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Three modes — `auth.forgotPassword`
|
|
10
|
-
|
|
11
|
-
`ProjectConfig.auth.forgotPassword` selects the operating mode:
|
|
12
|
-
|
|
13
|
-
| Mode | Behavior | Email peer dep |
|
|
14
|
-
| ------------- | -------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
15
|
-
| `'framework'` | `sendPasswordResetEmail` orchestrates token + email send. Reset URL points at the bundled `/reset-password` page. | `@luckystack/email` REQUIRED — lazy-imported on first call |
|
|
16
|
-
| `'custom'` | `sendPasswordResetEmail` returns `{ ok: false, reason: 'forgotPassword-not-framework' }`. Consumer wires their own email + UI using the primitives. | Not required — install `@luckystack/email` only if you call it yourself |
|
|
17
|
-
| `'disabled'` | Same as `'custom'` from the package's perspective. Convention is "the consumer-side UI does not expose a forgot-password link at all". | Not required |
|
|
18
|
-
|
|
19
|
-
The mode is read at call time via `getProjectConfig()`, so consumers can toggle it through `registerProjectConfig` without restarting. In practice, switching modes mid-process is uncommon — pick a mode at boot and stick with it.
|
|
20
|
-
|
|
21
|
-
## Token layout and hash-at-rest contract
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
const handle = issueOneTimeToken('-pwreset', auth.passwordResetTtlSeconds, userId);
|
|
25
|
-
await handle.store();
|
|
26
|
-
|
|
27
|
-
// Redis stores only the hash-derived key:
|
|
28
|
-
// <projectName>-pwreset:<sha256(handle.token)> -> userId
|
|
29
|
-
return handle.token; // raw 64-character token, returned only for delivery
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
- **Entropy** — 256 bits via `randomBytes(32)`, hex-encoded as a 64-character URL-safe token.
|
|
33
|
-
- **Hash at rest** — the raw token is never persisted. `issueOneTimeToken` derives a SHA-256 key suffix, so a Redis keyspace/backup leak does not yield a redeemable reset link.
|
|
34
|
-
- **Namespace** — `formatKey('-pwreset', sha256(token))`; the active project/tenant key formatter remains authoritative.
|
|
35
|
-
- **Single active token per user** — `${projectName}-pwreset-user:<userId>` points to the current hash key. Issuing a replacement deletes the previous outstanding token before storing the new one.
|
|
36
|
-
- **TTL** — both the hash record and per-user pointer use `auth.passwordResetTtlSeconds` (default 3600 seconds).
|
|
37
|
-
- **Storage boundary** — password-reset tokens intentionally use the core one-time-token Redis primitive; this is independent of the pluggable session-storage adapter.
|
|
38
|
-
|
|
39
|
-
## Primitives
|
|
40
|
-
|
|
41
|
-
### `createPasswordResetToken(userId)`
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
createPasswordResetToken(userId: string): Promise<string>;
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Invalidate the user's previous outstanding reset token, mint/store a new hash-at-rest token, persist the per-user pointer, and return the raw token. The caller is responsible for delivering it—normally in an `${appPublicUrl}/reset-password?token=<token>` URL.
|
|
48
|
-
|
|
49
|
-
The promise rejects when Redis cannot read/delete/store the token records. Framework-mode callers normalize that failure; custom callers should use the standard `tryCatch` helper when they need a typed degradation path.
|
|
50
|
-
|
|
51
|
-
### `consumePasswordResetToken(token)`
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
consumePasswordResetToken(token: string): Promise<string | null>;
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Delegates to `consumeOneTimeToken('-pwreset', token)`. The shared primitive hashes the supplied raw token to derive the Redis key and performs one `MULTI` containing `GET` + `DEL`. It returns the bound `userId` only when both commands succeed.
|
|
58
|
-
|
|
59
|
-
Returns `null` for an empty/malformed token, missing or expired hash record, an already-consumed token, or any transaction/DEL failure. A concurrent second redemption sees no value and fails closed, providing at-most-once consumption.
|
|
60
|
-
|
|
61
|
-
### `updatePasswordHash(userId, plaintext)`
|
|
62
|
-
|
|
63
|
-
```ts
|
|
64
|
-
updatePasswordHash(userId: string, plaintext: string): Promise<void>;
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Three-step: validate against policy → bcrypt-hash → write through `UserAdapter.update`.
|
|
68
|
-
|
|
69
|
-
```ts
|
|
70
|
-
const reason = validatePassword(plaintext);
|
|
71
|
-
if (reason) throw new PasswordPolicyError(reason);
|
|
72
|
-
const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);
|
|
73
|
-
const hashedPassword = await bcrypt.hash(plaintext, salt);
|
|
74
|
-
const patch: Record<string, unknown> = { password: hashedPassword };
|
|
75
|
-
await getUserAdapter().update(userId, patch);
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Throws `PasswordPolicyError` on policy violation. `UserAdapter.update` accepts a loose patch because custom user stores may expose different optional credential fields; no transport-typing cast is needed.
|
|
79
|
-
|
|
80
|
-
Note this primitive deliberately does NOT consume a reset token. The reset-password completion API is responsible for the sequence:
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
const userId = await consumePasswordResetToken(token);
|
|
84
|
-
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
85
|
-
await updatePasswordHash(userId, newPassword);
|
|
86
|
-
// optional: dispatch passwordResetCompleted hook
|
|
87
|
-
// optional: revokeUserSessions(userId)
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Splitting these lets consumers compose the same primitive into a "change my password" endpoint (no token, just verify the current password first), an admin-reset endpoint (no token, no verification), or a forced-reset flow.
|
|
91
|
-
|
|
92
|
-
### `verifyPassword(plaintext, hash)`
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
verifyPassword(plaintext: string, hash: string): Promise<boolean>;
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Bcrypt comparison. Used by the in-session "change my password" endpoint to verify the user's current password before accepting a new one.
|
|
99
|
-
|
|
100
|
-
### `PasswordPolicyError`
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
class PasswordPolicyError extends Error {
|
|
104
|
-
readonly errorCode: string;
|
|
105
|
-
constructor(errorCode: string) {
|
|
106
|
-
super(`Password policy violation: ${errorCode}`);
|
|
107
|
-
this.name = 'PasswordPolicyError';
|
|
108
|
-
this.errorCode = errorCode;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
`errorCode` is an i18n reason key like `login.passwordRequiresUppercase` or `login.passwordTooCommon`. Catch it in the calling API:
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
const [error] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
117
|
-
if (error instanceof PasswordPolicyError) {
|
|
118
|
-
return { status: false, reason: error.errorCode };
|
|
119
|
-
}
|
|
120
|
-
if (error) return { status: false, reason: 'api.internalServerError' };
|
|
121
|
-
return { status: true, reason: 'login.passwordChanged' };
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## Password policy — `validatePassword(plaintext)`
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
validatePassword(password: string): string | null;
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Returns `null` when the policy passes, or a reason-key on the FIRST failure. Checks run in order:
|
|
131
|
-
|
|
132
|
-
| Check | Reason key | Config slot |
|
|
133
|
-
| -------------------- | ------------------------------------- | ---------------------------------------- |
|
|
134
|
-
| `length < minLength` | `'login.passwordCharacterMinimum'` | `auth.passwordPolicy.minLength` |
|
|
135
|
-
| `length > maxLength` | `'login.passwordCharacterLimit'` | `auth.passwordPolicy.maxLength` |
|
|
136
|
-
| No `[A-Z]` | `'login.passwordRequiresUppercase'` | `auth.passwordPolicy.requireUppercase` |
|
|
137
|
-
| No `[a-z]` | `'login.passwordRequiresLowercase'` | `auth.passwordPolicy.requireLowercase` |
|
|
138
|
-
| No `\d` | `'login.passwordRequiresNumber'` | `auth.passwordPolicy.requireNumber` |
|
|
139
|
-
| No `[^A-Za-z0-9]` | `'login.passwordRequiresSpecial'` | `auth.passwordPolicy.requireSpecial` |
|
|
140
|
-
| In `COMMON_PASSWORDS` | `'login.passwordTooCommon'` | `auth.passwordPolicy.forbidCommon` |
|
|
141
|
-
| Custom validator | Whatever `customValidator` returns | `auth.passwordPolicy.customValidator` |
|
|
142
|
-
|
|
143
|
-
The common-passwords blocklist lives in [`./src/data/commonPasswords.ts`](../src/data/commonPasswords.ts) — a `Set<string>` for O(1) lookup. The check is case-insensitive (`password.toLowerCase()`), so `Password123` and `password123` are both rejected if either is in the list.
|
|
144
|
-
|
|
145
|
-
`customValidator` is a `(plaintext: string) => string | null` function on the project config. Use it for organization-specific policies (block-list of company-related words, NIST-style breach-database check, regex against the user's email):
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
registerProjectConfig({
|
|
149
|
-
auth: {
|
|
150
|
-
passwordPolicy: {
|
|
151
|
-
minLength: 12,
|
|
152
|
-
requireUppercase: true,
|
|
153
|
-
requireDigit: true,
|
|
154
|
-
customValidator: (plaintext) => {
|
|
155
|
-
if (plaintext.toLowerCase().includes('acme')) {
|
|
156
|
-
return 'login.passwordBrandWord';
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
Reason keys returned from `customValidator` should still be i18n keys — the installer-side translator must have a matching message.
|
|
166
|
-
|
|
167
|
-
## Framework-mode orchestrator — `sendPasswordResetEmail`
|
|
168
|
-
|
|
169
|
-
```ts
|
|
170
|
-
sendPasswordResetEmail({ email, brand? }: { email: string; brand?: string }): Promise<{ ok: boolean; reason?: string }>;
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
Full sequence in [`./src/forgotPassword.ts`](../src/forgotPassword.ts):
|
|
174
|
-
|
|
175
|
-
1. **Mode gate** — `if (config.auth.forgotPassword !== 'framework') return { ok: false, reason: 'forgotPassword-not-framework' }`. Non-framework modes short-circuit here.
|
|
176
|
-
2. **Brand resolution** — call-site `brand` > `config.auth.passwordResetBrand` > `'LuckyStack'`. The brand shows up in the email subject, the greeting, and the footer.
|
|
177
|
-
3. **Lazy import** — `await import('@luckystack/email')`. The `// @ts-expect-error optional peer dep` suppresses the TS error when `@luckystack/email` is not installed; runtime will throw a clear `Cannot find module` if the consumer is in framework-mode without installing the peer. The lazy import means modes other than `'framework'` never touch the email package — letting consumers skip `@luckystack/email` entirely.
|
|
178
|
-
4. **User lookup** — `userAdapter.findByEmail({ email, provider: 'credentials' })`. Looking up by the `'credentials'` provider scope means an OAuth-only user (no password set) cannot trigger a reset; the lookup misses, anti-enumeration kicks in.
|
|
179
|
-
5. **Anti-enumeration on miss** — fires `passwordResetRequested` with `matched: false`, returns `{ ok: true }`. The caller cannot distinguish "email matched" from "email didn't match" — both look like success. Prevents attackers from harvesting valid emails by polling the reset endpoint.
|
|
180
|
-
6. **Mint token** — `createPasswordResetToken(user.id)`.
|
|
181
|
-
7. **Hook dispatch** — `void dispatchHook('passwordResetRequested', { email, matched: true, userId, token, ttlSeconds })`. Fire-and-forget so a slow audit-log subscriber doesn't block the email send.
|
|
182
|
-
8. **Render the email** — `renderEmailLayout({ brand, title, intro, ctaLabel, ctaUrl, outro, footer })` from `@luckystack/email` produces `{ html, text }`. The reset URL is `${app.publicUrl}/reset-password?token=<urlEncodedToken>` with the trailing slash trimmed from `publicUrl`. `ttlMinutes = Math.round(ttlSeconds / 60)` so the email reads "expires in 60 minutes" without raw seconds.
|
|
183
|
-
9. **Send** — `sendEmail({ to, subject, html, text, adapterHint: 'transactional' })`. The `'transactional'` hint routes through the transactional sender when consumers registered separate marketing + transactional adapters via `registerEmailSenders`. Falls back to the default sender when only one is registered. See [`/docs/ARCHITECTURE_EMAIL.md`](../../../docs/ARCHITECTURE_EMAIL.md).
|
|
184
|
-
10. **Return** — `result.ok ? { ok: true } : { ok: false, reason: result.reason }`. Note that "send failure" IS surfaced via `ok: false` here — the anti-enumeration guarantee only covers the "user not found" miss, not infrastructure failure. The HTTP layer should still return a 200 to the client to preserve enumeration resistance, but log the `reason` for ops visibility.
|
|
185
|
-
|
|
186
|
-
### Lazy-import implementation detail
|
|
187
|
-
|
|
188
|
-
The `@ts-expect-error` is intentional and load-bearing:
|
|
189
|
-
|
|
190
|
-
```ts
|
|
191
|
-
const { sendEmail, renderEmailLayout } = await (
|
|
192
|
-
// @ts-expect-error optional peer dep — installed only when forgotPassword === 'framework'
|
|
193
|
-
import('@luckystack/email') as Promise<EmailModule>
|
|
194
|
-
);
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
`@luckystack/email` is in `peerDependenciesMeta` as optional. Projects that pick `'custom'` or `'disabled'` mode never install the peer, and TypeScript would error on the import — the `@ts-expect-error` is the standard escape hatch for optional peers. The shape is asserted at the call site via the local `EmailModule` interface.
|
|
198
|
-
|
|
199
|
-
## End-to-end reset flow
|
|
200
|
-
|
|
201
|
-
The framework-mode flow has two HTTP endpoints (wired by `@luckystack/server` via the file-based router):
|
|
202
|
-
|
|
203
|
-
```
|
|
204
|
-
1. POST /api/reset-password/sendReset/v1 → sendPasswordResetEmail({ email })
|
|
205
|
-
└ token hash now in Redis; raw token is emailed
|
|
206
|
-
2. POST /api/reset-password/confirmReset/v1 → consumePasswordResetToken(token)
|
|
207
|
-
└ updatePasswordHash(userId, newPassword)
|
|
208
|
-
└ revokeUserSessions(userId, currentToken?)
|
|
209
|
-
└ dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions })
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
Both endpoints live in the `create-luckystack-app` template under `src/reset-password/_api/` so consumers can edit them. The package itself only exposes the primitives.
|
|
213
|
-
|
|
214
|
-
For the in-session "change my password" flow:
|
|
215
|
-
|
|
216
|
-
```
|
|
217
|
-
POST /api/settings/changePassword/v1
|
|
218
|
-
1. verifyPassword(currentPassword, user.password)
|
|
219
|
-
2. updatePasswordHash(userId, newPassword)
|
|
220
|
-
3. revokeUserSessions(userId, currentToken) // keep this session alive
|
|
221
|
-
4. dispatchHook('passwordChanged', { userId, verifiedCurrent: true, revokedOtherSessions: true })
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
## Recommended pairing: revoke other sessions
|
|
225
|
-
|
|
226
|
-
After a successful reset OR change, calling `revokeUserSessions(userId, currentToken?)` ensures every other device the user was signed in on gets logged out. The recommended completion endpoint:
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
const userId = await consumePasswordResetToken(token);
|
|
230
|
-
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
231
|
-
|
|
232
|
-
const [updateError] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
233
|
-
if (updateError instanceof PasswordPolicyError) {
|
|
234
|
-
return { status: false, reason: updateError.errorCode };
|
|
235
|
-
}
|
|
236
|
-
if (updateError) return { status: false, reason: 'api.internalServerError' };
|
|
237
|
-
|
|
238
|
-
const revoked = await revokeUserSessions(userId);
|
|
239
|
-
await dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions: revoked > 0 });
|
|
240
|
-
|
|
241
|
-
return { status: true, reason: 'login.passwordResetSuccess' };
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
For the in-session change flow, pass the current token so the user stays signed in on the device they just changed it on:
|
|
245
|
-
|
|
246
|
-
```ts
|
|
247
|
-
const revoked = await revokeUserSessions(userId, currentToken);
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
The return value of `revokeUserSessions` is the number of sessions kicked, which the UI can surface as "You've been signed out of N other devices" for transparency.
|
|
251
|
-
|
|
252
|
-
## Custom mode — composing the primitives
|
|
253
|
-
|
|
254
|
-
When `auth.forgotPassword === 'custom'`, the consumer wires their own UI and email layer using the primitives:
|
|
255
|
-
|
|
256
|
-
```ts
|
|
257
|
-
// Consumer-side custom forgot-password endpoint
|
|
258
|
-
import { createPasswordResetToken } from '@luckystack/login';
|
|
259
|
-
import { mySmsClient } from './sms';
|
|
260
|
-
|
|
261
|
-
export const sendResetSms = async ({ phone }: { phone: string }) => {
|
|
262
|
-
const user = await prisma.user.findFirst({ where: { phone, provider: 'credentials' } });
|
|
263
|
-
if (!user) return { ok: true }; // anti-enumeration
|
|
264
|
-
const token = await createPasswordResetToken(user.id);
|
|
265
|
-
await mySmsClient.send(phone, `Reset code: ${token.slice(0, 6).toUpperCase()}`);
|
|
266
|
-
return { ok: true };
|
|
267
|
-
};
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
The SMS example uses the first 6 chars as a human-typeable code rather than the full 64-char hex string. The consumer-side completion endpoint then redeems by reconstructing the full token from a lookup table — or by storing a shorter token to begin with via a custom helper. The packaged primitives don't support short tokens directly; if you need them, mint + store in your own Redis namespace and skip `createPasswordResetToken` entirely.
|
|
271
|
-
|
|
272
|
-
## Disabled mode
|
|
273
|
-
|
|
274
|
-
`auth.forgotPassword === 'disabled'` is purely a convention — the package functions the same way as `'custom'`. The intent is for the installer-side UI to NOT render a "Forgot password?" link at all. This is appropriate for:
|
|
275
|
-
|
|
276
|
-
- B2B apps where password resets go through a customer success rep, not a self-serve flow.
|
|
277
|
-
- Internal tools where SSO is the only supported auth and the credentials provider is a break-glass admin-only path.
|
|
278
|
-
- Apps under a regulatory regime that forbids self-service reset (rare but real).
|
|
279
|
-
|
|
280
|
-
## Brand customization — `auth.passwordResetBrand`
|
|
281
|
-
|
|
282
|
-
Sets the default brand label used in the framework-mode email (subject, greeting, footer). Resolution order:
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
`'LuckyStack'` is the absolute fallback so a misconfigured project still produces a coherent email. In real deployments, set `auth.passwordResetBrand` in `config.ts` (or pass `brand` at the call site for per-tenant brands in a multi-tenant deployment).
|
|
289
|
-
|
|
290
|
-
## Hook flow
|
|
291
|
-
|
|
292
|
-
`passwordResetRequested` fires from `sendPasswordResetEmail` — for both matched and unmatched emails (anti-enumeration). Audit handlers MUST NOT differentiate UX between matched and unmatched. See [`./hooks.md`](./hooks.md) for the full payload shape.
|
|
293
|
-
|
|
294
|
-
`passwordResetCompleted` fires from the consumer-side completion endpoint AFTER the new password is written and `revokeUserSessions` runs. The package itself does NOT fire this hook — the bundled template does.
|
|
295
|
-
|
|
296
|
-
`passwordChanged` fires from the in-session change-password endpoint. Same — consumer-side dispatch, not framework-side. Carries `verifiedCurrent: boolean` to distinguish "user verified their old password" from "admin reset without verification".
|
|
297
|
-
|
|
298
|
-
## Config quick-reference
|
|
299
|
-
|
|
300
|
-
| Slot | Used by |
|
|
301
|
-
| ------------------------------------- | -------------------------------------------------------------- |
|
|
302
|
-
| `auth.bcryptRounds` | `updatePasswordHash` (salt rounds for the new hash) |
|
|
303
|
-
| `auth.passwordPolicy.minLength` | `validatePassword` (lower bound) |
|
|
304
|
-
| `auth.passwordPolicy.maxLength` | `validatePassword` (upper bound — anti-DoS on bcrypt) |
|
|
305
|
-
| `auth.passwordPolicy.requireUppercase` | `validatePassword` |
|
|
306
|
-
| `auth.passwordPolicy.requireLowercase` | `validatePassword` |
|
|
307
|
-
| `auth.passwordPolicy.requireNumber` | `validatePassword` |
|
|
308
|
-
| `auth.passwordPolicy.requireSpecial` | `validatePassword` |
|
|
309
|
-
| `auth.passwordPolicy.forbidCommon` | `validatePassword` (gate the common-passwords blocklist check) |
|
|
310
|
-
| `auth.passwordPolicy.customValidator` | `validatePassword` (final policy gate) |
|
|
311
|
-
| `auth.passwordResetTtlSeconds` | `createPasswordResetToken` (Redis EX), `sendPasswordResetEmail` (TTL string in email body) |
|
|
312
|
-
| `auth.passwordResetBrand` | `sendPasswordResetEmail` (fallback brand) |
|
|
313
|
-
| `auth.forgotPassword` | `sendPasswordResetEmail` (mode gate) |
|
|
314
|
-
| `app.publicUrl` | `sendPasswordResetEmail` (reset link host) |
|
|
315
|
-
|
|
316
|
-
## Why bcrypt and not argon2
|
|
317
|
-
|
|
318
|
-
bcrypt was picked because:
|
|
319
|
-
|
|
320
|
-
- It's been the industry standard for password hashing for two decades; bugs are well-known and patched.
|
|
321
|
-
- `bcryptjs` (the pure-JS port) works in serverless / edge runtimes where native modules are awkward.
|
|
322
|
-
- The cost factor (`bcryptRounds`) is tunable — set it higher on production behind sufficient CPU budget.
|
|
323
|
-
|
|
324
|
-
argon2 is theoretically stronger but the pure-JS implementation is slow and the native module is a deployment headache. If you have a strong opinion, you can register a custom `UserAdapter` whose `update` does an argon2 hash before storing — but you'd also need to swap `verifyPassword` and the credentials login path, which is more friction than the marginal security gain typically justifies.
|
|
325
|
-
|
|
326
|
-
If you DO want to upgrade, the recommended approach is "hash with argon2, store the result, and migrate users lazily on next login" — too disruptive to do in a one-shot rehash. Out of scope for this package.
|
|
327
|
-
|
|
328
|
-
## Related
|
|
329
|
-
|
|
330
|
-
- [`./credentials-auth.md`](./credentials-auth.md) — the login flow that uses `bcrypt.compare` against the same hash.
|
|
331
|
-
- [`./hooks.md`](./hooks.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged` payload shapes.
|
|
332
|
-
- [`./user-adapter.md`](./user-adapter.md) — `updatePasswordHash` writes through `UserAdapter.update`.
|
|
333
|
-
- [`./session-management.md`](./session-management.md) — `revokeUserSessions` (recommended pairing).
|
|
334
|
-
- Architecture — email: [`/docs/ARCHITECTURE_EMAIL.md`](../../../docs/ARCHITECTURE_EMAIL.md).
|
|
335
|
-
- Architecture — auth: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
|
|
5
|
+
An unknown email must not produce a distinguishable user-facing response, including from audit hooks. Apply password policy when changing a password, and consider revoking existing sessions after reset; a changed hash alone does not invalidate an already-issued session. This package uses bcrypt to avoid the deployment friction of a native hashing dependency; changing the algorithm requires a coordinated login/verification and migration plan, not only a storage adapter. Source: `../src/passwordPolicy.ts`, `../src/session.ts`, `./hooks.md`.
|