@luckystack/login 0.1.0 → 0.1.2
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/CHANGELOG.md +14 -14
- package/LICENSE +21 -21
- package/dist/index.js.map +1 -1
- package/docs/credentials-auth.md +219 -219
- package/docs/hooks.md +444 -444
- package/docs/logout-flow.md +235 -235
- package/docs/oauth-providers.md +431 -431
- package/docs/password-reset.md +343 -343
- package/docs/redirect-validation.md +390 -390
- package/docs/session-management.md +286 -286
- package/docs/user-adapter.md +353 -353
- package/package.json +6 -4
package/docs/password-reset.md
CHANGED
|
@@ -1,343 +1,343 @@
|
|
|
1
|
-
# Password reset
|
|
2
|
-
|
|
3
|
-
Deep-dive on the password-reset primitives, the framework-mode email orchestrator, and the password-policy validator. Canonical sources: [`./src/passwordReset.ts`](../src/passwordReset.ts), [`./src/forgotPassword.ts`](../src/forgotPassword.ts), [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts).
|
|
4
|
-
|
|
5
|
-
This is the password lifecycle from "user clicks Forgot password" through "new password landed in the user record". The flow is intentionally split into a small set of primitives that consumers can compose into a fully custom UI, with an opt-in `'framework'` mode that bundles all the pieces into a single end-to-end orchestrator.
|
|
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
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
const token = randomBytes(32).toString('hex'); // 64 char URL-safe string
|
|
25
|
-
const key = `${getProjectName()}-pwreset:${token}`;
|
|
26
|
-
await redis.set(key, userId, 'EX', auth.passwordResetTtlSeconds);
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
- **Entropy** — 256 bits via `randomBytes(32)`. Hex-encoded so it can be passed in a URL query string without any escaping.
|
|
30
|
-
- **Storage** — Redis. Single key holds the `userId`; one-time use means the redemption deletes the key.
|
|
31
|
-
- **Namespace** — `${projectName}-pwreset:<token>`. Shared `getProjectName()` helper means the namespace matches sessions, activeUsers, OAuth state, and rate-limit (no drift between Redis keys).
|
|
32
|
-
- **TTL** — `auth.passwordResetTtlSeconds`. Default 3600 (1 hour). Tighten for high-security deployments; loosen if your users frequently check email on a different device than they reset on.
|
|
33
|
-
|
|
34
|
-
## Primitives
|
|
35
|
-
|
|
36
|
-
### `createPasswordResetToken(userId)`
|
|
37
|
-
|
|
38
|
-
```ts
|
|
39
|
-
createPasswordResetToken(userId: string): Promise<string>;
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Mint + store + return. Caller is responsible for getting the token to the user — typically embedded in a `${appPublicUrl}/reset-password?token=<token>` URL inside an email, but custom modes might send it via SMS, push notification, or an in-app banner.
|
|
43
|
-
|
|
44
|
-
Always succeeds (returns the token string). If Redis is down, the underlying `redis.set` throws and propagates — wrap in `tryCatch` at the call site if you want to surface "email service degraded" rather than 500.
|
|
45
|
-
|
|
46
|
-
### `consumePasswordResetToken(token)`
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
consumePasswordResetToken(token: string): Promise<string | null>;
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Atomic `GET` + `DEL` in a Redis transaction:
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
const txResult = await redis.multi().get(key).del(key).exec();
|
|
56
|
-
const [getErr, value] = txResult[0];
|
|
57
|
-
if (getErr) return null;
|
|
58
|
-
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Returns the bound `userId` on success, `null` on:
|
|
62
|
-
|
|
63
|
-
- Empty or non-string `token` argument.
|
|
64
|
-
- Token expired (Redis returns null for the GET).
|
|
65
|
-
- Token already consumed (the DEL on a prior call already removed it).
|
|
66
|
-
- The transaction failed entirely (Redis network blip).
|
|
67
|
-
|
|
68
|
-
One-time-use is enforced by the atomic DEL — a second call with the same token returns `null` because the key is already gone. Replay attacks defeated.
|
|
69
|
-
|
|
70
|
-
### `updatePasswordHash(userId, plaintext)`
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
updatePasswordHash(userId: string, plaintext: string): Promise<void>;
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Three-step: validate against policy → bcrypt-hash → write through `UserAdapter.update`.
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
const reason = validatePassword(plaintext);
|
|
80
|
-
if (reason) throw new PasswordPolicyError(reason);
|
|
81
|
-
const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);
|
|
82
|
-
const hashedPassword = await bcrypt.hash(plaintext, salt);
|
|
83
|
-
await getUserAdapter().update(userId, { password: hashedPassword } as never);
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Throws `PasswordPolicyError` on policy violation. The cast to `never` on the patch tells TypeScript "we know `password` may not be a typed field on `UserRecord`, trust me" — the framework's `UserAdapter` interface is intentionally loose for this kind of optional column.
|
|
87
|
-
|
|
88
|
-
Note this primitive deliberately does NOT consume a reset token. The reset-password completion API is responsible for the sequence:
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
const userId = await consumePasswordResetToken(token);
|
|
92
|
-
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
93
|
-
await updatePasswordHash(userId, newPassword);
|
|
94
|
-
// optional: dispatch passwordResetCompleted hook
|
|
95
|
-
// optional: revokeUserSessions(userId)
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
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.
|
|
99
|
-
|
|
100
|
-
### `verifyPassword(plaintext, hash)`
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
verifyPassword(plaintext: string, hash: string): Promise<boolean>;
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
Bcrypt comparison. Used by the in-session "change my password" endpoint to verify the user's current password before accepting a new one.
|
|
107
|
-
|
|
108
|
-
### `PasswordPolicyError`
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
class PasswordPolicyError extends Error {
|
|
112
|
-
readonly errorCode: string;
|
|
113
|
-
constructor(errorCode: string) {
|
|
114
|
-
super(`Password policy violation: ${errorCode}`);
|
|
115
|
-
this.name = 'PasswordPolicyError';
|
|
116
|
-
this.errorCode = errorCode;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
`errorCode` is an i18n reason key like `login.passwordRequiresUppercase` or `login.passwordTooCommon`. Catch it in the calling API:
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
const [error] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
125
|
-
if (error instanceof PasswordPolicyError) {
|
|
126
|
-
return { status: false, reason: error.errorCode };
|
|
127
|
-
}
|
|
128
|
-
if (error) return { status: false, reason: 'api.internalServerError' };
|
|
129
|
-
return { status: true, reason: 'login.passwordChanged' };
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
## Password policy — `validatePassword(plaintext)`
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
validatePassword(password: string): string | null;
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Returns `null` when the policy passes, or a reason-key on the FIRST failure. Checks run in order:
|
|
139
|
-
|
|
140
|
-
| Check | Reason key | Config slot |
|
|
141
|
-
| -------------------- | ------------------------------------- | ---------------------------------------- |
|
|
142
|
-
| `length < minLength` | `'login.passwordCharacterMinimum'` | `auth.passwordPolicy.minLength` |
|
|
143
|
-
| `length > maxLength` | `'login.passwordCharacterLimit'` | `auth.passwordPolicy.maxLength` |
|
|
144
|
-
| No `[A-Z]` | `'login.passwordRequiresUppercase'` | `auth.passwordPolicy.requireUppercase` |
|
|
145
|
-
| No `[a-z]` | `'login.passwordRequiresLowercase'` | `auth.passwordPolicy.requireLowercase` |
|
|
146
|
-
| No `\d` | `'login.passwordRequiresNumber'` | `auth.passwordPolicy.requireNumber` |
|
|
147
|
-
| No `[^A-Za-z0-9]` | `'login.passwordRequiresSpecial'` | `auth.passwordPolicy.requireSpecial` |
|
|
148
|
-
| In `COMMON_PASSWORDS` | `'login.passwordTooCommon'` | `auth.passwordPolicy.forbidCommon` |
|
|
149
|
-
| Custom validator | Whatever `customValidator` returns | `auth.passwordPolicy.customValidator` |
|
|
150
|
-
|
|
151
|
-
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.
|
|
152
|
-
|
|
153
|
-
`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):
|
|
154
|
-
|
|
155
|
-
```ts
|
|
156
|
-
registerProjectConfig({
|
|
157
|
-
auth: {
|
|
158
|
-
passwordPolicy: {
|
|
159
|
-
minLength: 12,
|
|
160
|
-
requireUppercase: true,
|
|
161
|
-
requireDigit: true,
|
|
162
|
-
customValidator: (plaintext) => {
|
|
163
|
-
if (plaintext.toLowerCase().includes('acme')) {
|
|
164
|
-
return 'login.passwordBrandWord';
|
|
165
|
-
}
|
|
166
|
-
return null;
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
Reason keys returned from `customValidator` should still be i18n keys — the installer-side translator must have a matching message.
|
|
174
|
-
|
|
175
|
-
## Framework-mode orchestrator — `sendPasswordResetEmail`
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
sendPasswordResetEmail({ email, brand? }: { email: string; brand?: string }): Promise<{ ok: boolean; reason?: string }>;
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
Full sequence in [`./src/forgotPassword.ts`](../src/forgotPassword.ts):
|
|
182
|
-
|
|
183
|
-
1. **Mode gate** — `if (config.auth.forgotPassword !== 'framework') return { ok: false, reason: 'forgotPassword-not-framework' }`. Non-framework modes short-circuit here.
|
|
184
|
-
2. **Brand resolution** — call-site `brand` > `config.auth.passwordResetBrand` > `'LuckyStack'`. The brand shows up in the email subject, the greeting, and the footer.
|
|
185
|
-
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.
|
|
186
|
-
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.
|
|
187
|
-
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.
|
|
188
|
-
6. **Mint token** — `createPasswordResetToken(user.id)`.
|
|
189
|
-
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.
|
|
190
|
-
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.
|
|
191
|
-
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).
|
|
192
|
-
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.
|
|
193
|
-
|
|
194
|
-
### Lazy-import implementation detail
|
|
195
|
-
|
|
196
|
-
The `@ts-expect-error` is intentional and load-bearing:
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
const { sendEmail, renderEmailLayout } = await (
|
|
200
|
-
// @ts-expect-error optional peer dep — installed only when forgotPassword === 'framework'
|
|
201
|
-
import('@luckystack/email') as Promise<EmailModule>
|
|
202
|
-
);
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
`@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.
|
|
206
|
-
|
|
207
|
-
## End-to-end reset flow
|
|
208
|
-
|
|
209
|
-
The framework-mode flow has two HTTP endpoints (wired by `@luckystack/server` via the file-based router):
|
|
210
|
-
|
|
211
|
-
```
|
|
212
|
-
1. POST /api/login/sendReset/v1 → sendPasswordResetEmail({ email })
|
|
213
|
-
└ token now in Redis, email on its way
|
|
214
|
-
2. POST /api/login/completeReset/v1 → consumePasswordResetToken(token)
|
|
215
|
-
└ updatePasswordHash(userId, newPassword)
|
|
216
|
-
└ revokeUserSessions(userId, currentToken?)
|
|
217
|
-
└ dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions })
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
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.
|
|
221
|
-
|
|
222
|
-
For the in-session "change my password" flow:
|
|
223
|
-
|
|
224
|
-
```
|
|
225
|
-
POST /api/settings/changePassword/v1
|
|
226
|
-
1. verifyPassword(currentPassword, user.password)
|
|
227
|
-
2. updatePasswordHash(userId, newPassword)
|
|
228
|
-
3. revokeUserSessions(userId, currentToken) // keep this session alive
|
|
229
|
-
4. dispatchHook('passwordChanged', { userId, verifiedCurrent: true, revokedOtherSessions: true })
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
## Recommended pairing: revoke other sessions
|
|
233
|
-
|
|
234
|
-
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:
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
const userId = await consumePasswordResetToken(token);
|
|
238
|
-
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
239
|
-
|
|
240
|
-
const [updateError] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
241
|
-
if (updateError instanceof PasswordPolicyError) {
|
|
242
|
-
return { status: false, reason: updateError.errorCode };
|
|
243
|
-
}
|
|
244
|
-
if (updateError) return { status: false, reason: 'api.internalServerError' };
|
|
245
|
-
|
|
246
|
-
const revoked = await revokeUserSessions(userId);
|
|
247
|
-
await dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions: revoked > 0 });
|
|
248
|
-
|
|
249
|
-
return { status: true, reason: 'login.passwordResetSuccess' };
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
For the in-session change flow, pass the current token so the user stays signed in on the device they just changed it on:
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
const revoked = await revokeUserSessions(userId, currentToken);
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
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.
|
|
259
|
-
|
|
260
|
-
## Custom mode — composing the primitives
|
|
261
|
-
|
|
262
|
-
When `auth.forgotPassword === 'custom'`, the consumer wires their own UI and email layer using the primitives:
|
|
263
|
-
|
|
264
|
-
```ts
|
|
265
|
-
// Consumer-side custom forgot-password endpoint
|
|
266
|
-
import { createPasswordResetToken } from '@luckystack/login';
|
|
267
|
-
import { mySmsClient } from './sms';
|
|
268
|
-
|
|
269
|
-
export const sendResetSms = async ({ phone }: { phone: string }) => {
|
|
270
|
-
const user = await prisma.user.findFirst({ where: { phone, provider: 'credentials' } });
|
|
271
|
-
if (!user) return { ok: true }; // anti-enumeration
|
|
272
|
-
const token = await createPasswordResetToken(user.id);
|
|
273
|
-
await mySmsClient.send(phone, `Reset code: ${token.slice(0, 6).toUpperCase()}`);
|
|
274
|
-
return { ok: true };
|
|
275
|
-
};
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
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.
|
|
279
|
-
|
|
280
|
-
## Disabled mode
|
|
281
|
-
|
|
282
|
-
`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:
|
|
283
|
-
|
|
284
|
-
- B2B apps where password resets go through a customer success rep, not a self-serve flow.
|
|
285
|
-
- Internal tools where SSO is the only supported auth and the credentials provider is a break-glass admin-only path.
|
|
286
|
-
- Apps under a regulatory regime that forbids self-service reset (rare but real).
|
|
287
|
-
|
|
288
|
-
## Brand customization — `auth.passwordResetBrand`
|
|
289
|
-
|
|
290
|
-
Sets the default brand label used in the framework-mode email (subject, greeting, footer). Resolution order:
|
|
291
|
-
|
|
292
|
-
```ts
|
|
293
|
-
const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
`'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).
|
|
297
|
-
|
|
298
|
-
## Hook flow
|
|
299
|
-
|
|
300
|
-
`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.
|
|
301
|
-
|
|
302
|
-
`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.
|
|
303
|
-
|
|
304
|
-
`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".
|
|
305
|
-
|
|
306
|
-
## Config quick-reference
|
|
307
|
-
|
|
308
|
-
| Slot | Used by |
|
|
309
|
-
| ------------------------------------- | -------------------------------------------------------------- |
|
|
310
|
-
| `auth.bcryptRounds` | `updatePasswordHash` (salt rounds for the new hash) |
|
|
311
|
-
| `auth.passwordPolicy.minLength` | `validatePassword` (lower bound) |
|
|
312
|
-
| `auth.passwordPolicy.maxLength` | `validatePassword` (upper bound — anti-DoS on bcrypt) |
|
|
313
|
-
| `auth.passwordPolicy.requireUppercase` | `validatePassword` |
|
|
314
|
-
| `auth.passwordPolicy.requireLowercase` | `validatePassword` |
|
|
315
|
-
| `auth.passwordPolicy.requireNumber` | `validatePassword` |
|
|
316
|
-
| `auth.passwordPolicy.requireSpecial` | `validatePassword` |
|
|
317
|
-
| `auth.passwordPolicy.forbidCommon` | `validatePassword` (gate the common-passwords blocklist check) |
|
|
318
|
-
| `auth.passwordPolicy.customValidator` | `validatePassword` (final policy gate) |
|
|
319
|
-
| `auth.passwordResetTtlSeconds` | `createPasswordResetToken` (Redis EX), `sendPasswordResetEmail` (TTL string in email body) |
|
|
320
|
-
| `auth.passwordResetBrand` | `sendPasswordResetEmail` (fallback brand) |
|
|
321
|
-
| `auth.forgotPassword` | `sendPasswordResetEmail` (mode gate) |
|
|
322
|
-
| `app.publicUrl` | `sendPasswordResetEmail` (reset link host) |
|
|
323
|
-
|
|
324
|
-
## Why bcrypt and not argon2
|
|
325
|
-
|
|
326
|
-
bcrypt was picked because:
|
|
327
|
-
|
|
328
|
-
- It's been the industry standard for password hashing for two decades; bugs are well-known and patched.
|
|
329
|
-
- `bcryptjs` (the pure-JS port) works in serverless / edge runtimes where native modules are awkward.
|
|
330
|
-
- The cost factor (`bcryptRounds`) is tunable — set it higher on production behind sufficient CPU budget.
|
|
331
|
-
|
|
332
|
-
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.
|
|
333
|
-
|
|
334
|
-
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.
|
|
335
|
-
|
|
336
|
-
## Related
|
|
337
|
-
|
|
338
|
-
- [`./credentials-auth.md`](./credentials-auth.md) — the login flow that uses `bcrypt.compare` against the same hash.
|
|
339
|
-
- [`./hooks.md`](./hooks.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged` payload shapes.
|
|
340
|
-
- [`./user-adapter.md`](./user-adapter.md) — `updatePasswordHash` writes through `UserAdapter.update`.
|
|
341
|
-
- [`./session-management.md`](./session-management.md) — `revokeUserSessions` (recommended pairing).
|
|
342
|
-
- Architecture — email: [`/docs/ARCHITECTURE_EMAIL.md`](../../../docs/ARCHITECTURE_EMAIL.md).
|
|
343
|
-
- Architecture — auth: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
|
|
1
|
+
# Password reset
|
|
2
|
+
|
|
3
|
+
Deep-dive on the password-reset primitives, the framework-mode email orchestrator, and the password-policy validator. Canonical sources: [`./src/passwordReset.ts`](../src/passwordReset.ts), [`./src/forgotPassword.ts`](../src/forgotPassword.ts), [`./src/passwordPolicy.ts`](../src/passwordPolicy.ts).
|
|
4
|
+
|
|
5
|
+
This is the password lifecycle from "user clicks Forgot password" through "new password landed in the user record". The flow is intentionally split into a small set of primitives that consumers can compose into a fully custom UI, with an opt-in `'framework'` mode that bundles all the pieces into a single end-to-end orchestrator.
|
|
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
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const token = randomBytes(32).toString('hex'); // 64 char URL-safe string
|
|
25
|
+
const key = `${getProjectName()}-pwreset:${token}`;
|
|
26
|
+
await redis.set(key, userId, 'EX', auth.passwordResetTtlSeconds);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Entropy** — 256 bits via `randomBytes(32)`. Hex-encoded so it can be passed in a URL query string without any escaping.
|
|
30
|
+
- **Storage** — Redis. Single key holds the `userId`; one-time use means the redemption deletes the key.
|
|
31
|
+
- **Namespace** — `${projectName}-pwreset:<token>`. Shared `getProjectName()` helper means the namespace matches sessions, activeUsers, OAuth state, and rate-limit (no drift between Redis keys).
|
|
32
|
+
- **TTL** — `auth.passwordResetTtlSeconds`. Default 3600 (1 hour). Tighten for high-security deployments; loosen if your users frequently check email on a different device than they reset on.
|
|
33
|
+
|
|
34
|
+
## Primitives
|
|
35
|
+
|
|
36
|
+
### `createPasswordResetToken(userId)`
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
createPasswordResetToken(userId: string): Promise<string>;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Mint + store + return. Caller is responsible for getting the token to the user — typically embedded in a `${appPublicUrl}/reset-password?token=<token>` URL inside an email, but custom modes might send it via SMS, push notification, or an in-app banner.
|
|
43
|
+
|
|
44
|
+
Always succeeds (returns the token string). If Redis is down, the underlying `redis.set` throws and propagates — wrap in `tryCatch` at the call site if you want to surface "email service degraded" rather than 500.
|
|
45
|
+
|
|
46
|
+
### `consumePasswordResetToken(token)`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
consumePasswordResetToken(token: string): Promise<string | null>;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Atomic `GET` + `DEL` in a Redis transaction:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const txResult = await redis.multi().get(key).del(key).exec();
|
|
56
|
+
const [getErr, value] = txResult[0];
|
|
57
|
+
if (getErr) return null;
|
|
58
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Returns the bound `userId` on success, `null` on:
|
|
62
|
+
|
|
63
|
+
- Empty or non-string `token` argument.
|
|
64
|
+
- Token expired (Redis returns null for the GET).
|
|
65
|
+
- Token already consumed (the DEL on a prior call already removed it).
|
|
66
|
+
- The transaction failed entirely (Redis network blip).
|
|
67
|
+
|
|
68
|
+
One-time-use is enforced by the atomic DEL — a second call with the same token returns `null` because the key is already gone. Replay attacks defeated.
|
|
69
|
+
|
|
70
|
+
### `updatePasswordHash(userId, plaintext)`
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
updatePasswordHash(userId: string, plaintext: string): Promise<void>;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Three-step: validate against policy → bcrypt-hash → write through `UserAdapter.update`.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const reason = validatePassword(plaintext);
|
|
80
|
+
if (reason) throw new PasswordPolicyError(reason);
|
|
81
|
+
const salt = await bcrypt.genSalt(getProjectConfig().auth.bcryptRounds);
|
|
82
|
+
const hashedPassword = await bcrypt.hash(plaintext, salt);
|
|
83
|
+
await getUserAdapter().update(userId, { password: hashedPassword } as never);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Throws `PasswordPolicyError` on policy violation. The cast to `never` on the patch tells TypeScript "we know `password` may not be a typed field on `UserRecord`, trust me" — the framework's `UserAdapter` interface is intentionally loose for this kind of optional column.
|
|
87
|
+
|
|
88
|
+
Note this primitive deliberately does NOT consume a reset token. The reset-password completion API is responsible for the sequence:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const userId = await consumePasswordResetToken(token);
|
|
92
|
+
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
93
|
+
await updatePasswordHash(userId, newPassword);
|
|
94
|
+
// optional: dispatch passwordResetCompleted hook
|
|
95
|
+
// optional: revokeUserSessions(userId)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
### `verifyPassword(plaintext, hash)`
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
verifyPassword(plaintext: string, hash: string): Promise<boolean>;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Bcrypt comparison. Used by the in-session "change my password" endpoint to verify the user's current password before accepting a new one.
|
|
107
|
+
|
|
108
|
+
### `PasswordPolicyError`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
class PasswordPolicyError extends Error {
|
|
112
|
+
readonly errorCode: string;
|
|
113
|
+
constructor(errorCode: string) {
|
|
114
|
+
super(`Password policy violation: ${errorCode}`);
|
|
115
|
+
this.name = 'PasswordPolicyError';
|
|
116
|
+
this.errorCode = errorCode;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`errorCode` is an i18n reason key like `login.passwordRequiresUppercase` or `login.passwordTooCommon`. Catch it in the calling API:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const [error] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
125
|
+
if (error instanceof PasswordPolicyError) {
|
|
126
|
+
return { status: false, reason: error.errorCode };
|
|
127
|
+
}
|
|
128
|
+
if (error) return { status: false, reason: 'api.internalServerError' };
|
|
129
|
+
return { status: true, reason: 'login.passwordChanged' };
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Password policy — `validatePassword(plaintext)`
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
validatePassword(password: string): string | null;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Returns `null` when the policy passes, or a reason-key on the FIRST failure. Checks run in order:
|
|
139
|
+
|
|
140
|
+
| Check | Reason key | Config slot |
|
|
141
|
+
| -------------------- | ------------------------------------- | ---------------------------------------- |
|
|
142
|
+
| `length < minLength` | `'login.passwordCharacterMinimum'` | `auth.passwordPolicy.minLength` |
|
|
143
|
+
| `length > maxLength` | `'login.passwordCharacterLimit'` | `auth.passwordPolicy.maxLength` |
|
|
144
|
+
| No `[A-Z]` | `'login.passwordRequiresUppercase'` | `auth.passwordPolicy.requireUppercase` |
|
|
145
|
+
| No `[a-z]` | `'login.passwordRequiresLowercase'` | `auth.passwordPolicy.requireLowercase` |
|
|
146
|
+
| No `\d` | `'login.passwordRequiresNumber'` | `auth.passwordPolicy.requireNumber` |
|
|
147
|
+
| No `[^A-Za-z0-9]` | `'login.passwordRequiresSpecial'` | `auth.passwordPolicy.requireSpecial` |
|
|
148
|
+
| In `COMMON_PASSWORDS` | `'login.passwordTooCommon'` | `auth.passwordPolicy.forbidCommon` |
|
|
149
|
+
| Custom validator | Whatever `customValidator` returns | `auth.passwordPolicy.customValidator` |
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
`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):
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
registerProjectConfig({
|
|
157
|
+
auth: {
|
|
158
|
+
passwordPolicy: {
|
|
159
|
+
minLength: 12,
|
|
160
|
+
requireUppercase: true,
|
|
161
|
+
requireDigit: true,
|
|
162
|
+
customValidator: (plaintext) => {
|
|
163
|
+
if (plaintext.toLowerCase().includes('acme')) {
|
|
164
|
+
return 'login.passwordBrandWord';
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Reason keys returned from `customValidator` should still be i18n keys — the installer-side translator must have a matching message.
|
|
174
|
+
|
|
175
|
+
## Framework-mode orchestrator — `sendPasswordResetEmail`
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
sendPasswordResetEmail({ email, brand? }: { email: string; brand?: string }): Promise<{ ok: boolean; reason?: string }>;
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Full sequence in [`./src/forgotPassword.ts`](../src/forgotPassword.ts):
|
|
182
|
+
|
|
183
|
+
1. **Mode gate** — `if (config.auth.forgotPassword !== 'framework') return { ok: false, reason: 'forgotPassword-not-framework' }`. Non-framework modes short-circuit here.
|
|
184
|
+
2. **Brand resolution** — call-site `brand` > `config.auth.passwordResetBrand` > `'LuckyStack'`. The brand shows up in the email subject, the greeting, and the footer.
|
|
185
|
+
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.
|
|
186
|
+
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.
|
|
187
|
+
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.
|
|
188
|
+
6. **Mint token** — `createPasswordResetToken(user.id)`.
|
|
189
|
+
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.
|
|
190
|
+
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.
|
|
191
|
+
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).
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
### Lazy-import implementation detail
|
|
195
|
+
|
|
196
|
+
The `@ts-expect-error` is intentional and load-bearing:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const { sendEmail, renderEmailLayout } = await (
|
|
200
|
+
// @ts-expect-error optional peer dep — installed only when forgotPassword === 'framework'
|
|
201
|
+
import('@luckystack/email') as Promise<EmailModule>
|
|
202
|
+
);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`@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.
|
|
206
|
+
|
|
207
|
+
## End-to-end reset flow
|
|
208
|
+
|
|
209
|
+
The framework-mode flow has two HTTP endpoints (wired by `@luckystack/server` via the file-based router):
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
1. POST /api/login/sendReset/v1 → sendPasswordResetEmail({ email })
|
|
213
|
+
└ token now in Redis, email on its way
|
|
214
|
+
2. POST /api/login/completeReset/v1 → consumePasswordResetToken(token)
|
|
215
|
+
└ updatePasswordHash(userId, newPassword)
|
|
216
|
+
└ revokeUserSessions(userId, currentToken?)
|
|
217
|
+
└ dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions })
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
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.
|
|
221
|
+
|
|
222
|
+
For the in-session "change my password" flow:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
POST /api/settings/changePassword/v1
|
|
226
|
+
1. verifyPassword(currentPassword, user.password)
|
|
227
|
+
2. updatePasswordHash(userId, newPassword)
|
|
228
|
+
3. revokeUserSessions(userId, currentToken) // keep this session alive
|
|
229
|
+
4. dispatchHook('passwordChanged', { userId, verifiedCurrent: true, revokedOtherSessions: true })
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Recommended pairing: revoke other sessions
|
|
233
|
+
|
|
234
|
+
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:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
const userId = await consumePasswordResetToken(token);
|
|
238
|
+
if (!userId) return { status: false, reason: 'login.invalidResetToken' };
|
|
239
|
+
|
|
240
|
+
const [updateError] = await tryCatch(() => updatePasswordHash(userId, newPassword));
|
|
241
|
+
if (updateError instanceof PasswordPolicyError) {
|
|
242
|
+
return { status: false, reason: updateError.errorCode };
|
|
243
|
+
}
|
|
244
|
+
if (updateError) return { status: false, reason: 'api.internalServerError' };
|
|
245
|
+
|
|
246
|
+
const revoked = await revokeUserSessions(userId);
|
|
247
|
+
await dispatchHook('passwordResetCompleted', { userId, revokedOtherSessions: revoked > 0 });
|
|
248
|
+
|
|
249
|
+
return { status: true, reason: 'login.passwordResetSuccess' };
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
For the in-session change flow, pass the current token so the user stays signed in on the device they just changed it on:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const revoked = await revokeUserSessions(userId, currentToken);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
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.
|
|
259
|
+
|
|
260
|
+
## Custom mode — composing the primitives
|
|
261
|
+
|
|
262
|
+
When `auth.forgotPassword === 'custom'`, the consumer wires their own UI and email layer using the primitives:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// Consumer-side custom forgot-password endpoint
|
|
266
|
+
import { createPasswordResetToken } from '@luckystack/login';
|
|
267
|
+
import { mySmsClient } from './sms';
|
|
268
|
+
|
|
269
|
+
export const sendResetSms = async ({ phone }: { phone: string }) => {
|
|
270
|
+
const user = await prisma.user.findFirst({ where: { phone, provider: 'credentials' } });
|
|
271
|
+
if (!user) return { ok: true }; // anti-enumeration
|
|
272
|
+
const token = await createPasswordResetToken(user.id);
|
|
273
|
+
await mySmsClient.send(phone, `Reset code: ${token.slice(0, 6).toUpperCase()}`);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
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.
|
|
279
|
+
|
|
280
|
+
## Disabled mode
|
|
281
|
+
|
|
282
|
+
`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:
|
|
283
|
+
|
|
284
|
+
- B2B apps where password resets go through a customer success rep, not a self-serve flow.
|
|
285
|
+
- Internal tools where SSO is the only supported auth and the credentials provider is a break-glass admin-only path.
|
|
286
|
+
- Apps under a regulatory regime that forbids self-service reset (rare but real).
|
|
287
|
+
|
|
288
|
+
## Brand customization — `auth.passwordResetBrand`
|
|
289
|
+
|
|
290
|
+
Sets the default brand label used in the framework-mode email (subject, greeting, footer). Resolution order:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const resolvedBrand = brand ?? config.auth.passwordResetBrand ?? 'LuckyStack';
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`'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).
|
|
297
|
+
|
|
298
|
+
## Hook flow
|
|
299
|
+
|
|
300
|
+
`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.
|
|
301
|
+
|
|
302
|
+
`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.
|
|
303
|
+
|
|
304
|
+
`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".
|
|
305
|
+
|
|
306
|
+
## Config quick-reference
|
|
307
|
+
|
|
308
|
+
| Slot | Used by |
|
|
309
|
+
| ------------------------------------- | -------------------------------------------------------------- |
|
|
310
|
+
| `auth.bcryptRounds` | `updatePasswordHash` (salt rounds for the new hash) |
|
|
311
|
+
| `auth.passwordPolicy.minLength` | `validatePassword` (lower bound) |
|
|
312
|
+
| `auth.passwordPolicy.maxLength` | `validatePassword` (upper bound — anti-DoS on bcrypt) |
|
|
313
|
+
| `auth.passwordPolicy.requireUppercase` | `validatePassword` |
|
|
314
|
+
| `auth.passwordPolicy.requireLowercase` | `validatePassword` |
|
|
315
|
+
| `auth.passwordPolicy.requireNumber` | `validatePassword` |
|
|
316
|
+
| `auth.passwordPolicy.requireSpecial` | `validatePassword` |
|
|
317
|
+
| `auth.passwordPolicy.forbidCommon` | `validatePassword` (gate the common-passwords blocklist check) |
|
|
318
|
+
| `auth.passwordPolicy.customValidator` | `validatePassword` (final policy gate) |
|
|
319
|
+
| `auth.passwordResetTtlSeconds` | `createPasswordResetToken` (Redis EX), `sendPasswordResetEmail` (TTL string in email body) |
|
|
320
|
+
| `auth.passwordResetBrand` | `sendPasswordResetEmail` (fallback brand) |
|
|
321
|
+
| `auth.forgotPassword` | `sendPasswordResetEmail` (mode gate) |
|
|
322
|
+
| `app.publicUrl` | `sendPasswordResetEmail` (reset link host) |
|
|
323
|
+
|
|
324
|
+
## Why bcrypt and not argon2
|
|
325
|
+
|
|
326
|
+
bcrypt was picked because:
|
|
327
|
+
|
|
328
|
+
- It's been the industry standard for password hashing for two decades; bugs are well-known and patched.
|
|
329
|
+
- `bcryptjs` (the pure-JS port) works in serverless / edge runtimes where native modules are awkward.
|
|
330
|
+
- The cost factor (`bcryptRounds`) is tunable — set it higher on production behind sufficient CPU budget.
|
|
331
|
+
|
|
332
|
+
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.
|
|
333
|
+
|
|
334
|
+
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.
|
|
335
|
+
|
|
336
|
+
## Related
|
|
337
|
+
|
|
338
|
+
- [`./credentials-auth.md`](./credentials-auth.md) — the login flow that uses `bcrypt.compare` against the same hash.
|
|
339
|
+
- [`./hooks.md`](./hooks.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged` payload shapes.
|
|
340
|
+
- [`./user-adapter.md`](./user-adapter.md) — `updatePasswordHash` writes through `UserAdapter.update`.
|
|
341
|
+
- [`./session-management.md`](./session-management.md) — `revokeUserSessions` (recommended pairing).
|
|
342
|
+
- Architecture — email: [`/docs/ARCHITECTURE_EMAIL.md`](../../../docs/ARCHITECTURE_EMAIL.md).
|
|
343
|
+
- Architecture — auth: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
|