@luckystack/login 0.6.6 → 0.7.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/CHANGELOG.md +51 -51
- package/CLAUDE.md +194 -194
- package/README.md +239 -239
- package/dist/{chunk-HJHCX2JO.js → chunk-L5MHCPNN.js} +1 -1
- package/dist/chunk-L5MHCPNN.js.map +1 -0
- package/dist/{chunk-5WBFVSMW.js → chunk-WFPYOCFC.js} +3 -3
- package/dist/chunk-WFPYOCFC.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/logout-OBO6AF4R.js +7 -0
- package/dist/register.js +1 -1
- package/dist/register.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 +440 -440
- package/docs/password-reset.md +343 -343
- package/docs/redirect-validation.md +390 -390
- package/docs/session-management.md +285 -285
- package/docs/user-adapter.md +353 -353
- package/package.json +3 -3
- package/dist/chunk-5WBFVSMW.js.map +0 -1
- package/dist/chunk-HJHCX2JO.js.map +0 -1
- package/dist/logout-RX77TPUH.js +0 -7
- /package/dist/{logout-RX77TPUH.js.map → logout-OBO6AF4R.js.map} +0 -0
package/docs/hooks.md
CHANGED
|
@@ -1,444 +1,444 @@
|
|
|
1
|
-
# Lifecycle hooks
|
|
2
|
-
|
|
3
|
-
Deep-dive on the authentication and session lifecycle hooks that `@luckystack/login` declares, fires, and lets consumers subscribe to. The hook bus itself lives in `@luckystack/core` (`registerHook` / `dispatchHook`); this package extends the core's `HookPayloads` interface via TypeScript module augmentation and dispatches against it from `login.ts`, `logout.ts`, `session.ts`, and `forgotPassword.ts`.
|
|
4
|
-
|
|
5
|
-
Canonical sources:
|
|
6
|
-
|
|
7
|
-
- Payload types: [`./src/hookPayloads.ts`](../src/hookPayloads.ts)
|
|
8
|
-
- Dispatch sites: [`./src/login.ts`](../src/login.ts), [`./src/logout.ts`](../src/logout.ts), [`./src/session.ts`](../src/session.ts), [`./src/forgotPassword.ts`](../src/forgotPassword.ts)
|
|
9
|
-
- Bus implementation: [`@luckystack/core/src/hooks/registry.ts`](../../core/src/hooks/registry.ts)
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## Module augmentation
|
|
14
|
-
|
|
15
|
-
`@luckystack/login` does not define a parallel hook bus. It augments the shared `HookPayloads` interface that `@luckystack/core` exposes:
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
// packages/login/src/hookPayloads.ts
|
|
19
|
-
declare module '@luckystack/core' {
|
|
20
|
-
interface HookPayloads {
|
|
21
|
-
preLogin: PreLoginPayload;
|
|
22
|
-
postLogin: PostLoginPayload;
|
|
23
|
-
preRegister: PreRegisterPayload;
|
|
24
|
-
postRegister: PostRegisterPayload;
|
|
25
|
-
preLogout: PreLogoutPayload;
|
|
26
|
-
postLogout: PostLogoutPayload;
|
|
27
|
-
preSessionCreate: PreSessionCreatePayload;
|
|
28
|
-
postSessionCreate: PostSessionCreatePayload;
|
|
29
|
-
preSessionDelete: PreSessionDeletePayload;
|
|
30
|
-
postSessionDelete: PostSessionDeletePayload;
|
|
31
|
-
passwordResetRequested: PasswordResetRequestedPayload;
|
|
32
|
-
passwordResetCompleted: PasswordResetCompletedPayload;
|
|
33
|
-
passwordChanged: PasswordChangedPayload;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
This module is referenced from `index.ts` as a side-effect import:
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
import './hookPayloads';
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
The side-effect import has no runtime cost (the file only contains type declarations) but makes the dependency explicit for readers and ensures the augmentation lands in any TypeScript program that imports `@luckystack/login` — without the side-effect import, projects with strict tree-shaking on type-only imports could lose the declaration.
|
|
45
|
-
|
|
46
|
-
`preSessionRefresh` / `postSessionRefresh` are owned by `@luckystack/core` (declared in `packages/core/src/hooks/types.ts`) but dispatched from this package's `getSession`. They are listed here because subscribing to them from `@luckystack/login`-aware code is identical to the auth hooks.
|
|
47
|
-
|
|
48
|
-
## Hook table
|
|
49
|
-
|
|
50
|
-
| Hook | Aborts on stop signal | Fires from | Async? |
|
|
51
|
-
| -------------------------- | ----------------------------------- | ---------------------------------------------------------------- | ------ |
|
|
52
|
-
| `preLogin` | Yes — login refused with `errorCode` | `loginWithCredentialsCore`, `loginCallback` | Yes |
|
|
53
|
-
| `postLogin` | No — `void` return | `loginWithCredentialsCore` (success), `loginCallback` (success) | Yes |
|
|
54
|
-
| `preRegister` | Yes — register refused | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
|
|
55
|
-
| `postRegister` | No | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
|
|
56
|
-
| `preLogout` | Yes — logout aborted, socket gets `'error'` | `logout` | Yes |
|
|
57
|
-
| `postLogout` | No | `logout` (success) | Yes |
|
|
58
|
-
| `preSessionCreate` | Yes — save skipped | `saveSession(token, user, true)` | Yes |
|
|
59
|
-
| `postSessionCreate` | No | `saveSession(token, user, true)` (success) | Yes |
|
|
60
|
-
| `preSessionDelete` | Yes — delete refused, returns `false` | `deleteSession` | Yes |
|
|
61
|
-
| `postSessionDelete` | No | `deleteSession` (success) | Yes |
|
|
62
|
-
| `preSessionRefresh` | Yes — TTL extend skipped | `getSession` (every authenticated read) | Yes |
|
|
63
|
-
| `postSessionRefresh` | No | `getSession` (every authenticated read) | Yes |
|
|
64
|
-
| `passwordResetRequested` | No — fired for matched AND unmatched | `sendPasswordResetEmail` | Yes (fire-and-forget via `void dispatchHook(...)`) |
|
|
65
|
-
| `passwordResetCompleted` | No | The consumer-side reset-password completion API | Yes |
|
|
66
|
-
| `passwordChanged` | No | The consumer-side in-session password-change API | Yes |
|
|
67
|
-
|
|
68
|
-
`passwordResetCompleted` and `passwordChanged` are declared by this package but dispatched by the installer-side `reset-password/_api/*` and `settings/_api/changePassword_v1` routes — see the `create-luckystack-app` template. They live here because the payload shape is part of the auth contract.
|
|
69
|
-
|
|
70
|
-
## Stop-signal contract
|
|
71
|
-
|
|
72
|
-
`pre*` hooks can abort the operation by returning a `HookStopSignal`:
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
import { registerHook } from '@luckystack/core';
|
|
76
|
-
|
|
77
|
-
registerHook('preLogin', async ({ email, provider }) => {
|
|
78
|
-
if (provider !== 'credentials') return;
|
|
79
|
-
const user = await prisma.user.findFirst({ where: { email } });
|
|
80
|
-
if (user && !user.verified) {
|
|
81
|
-
return { stop: true, errorCode: 'login.notVerified' };
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
What "stop" means depends on the hook:
|
|
87
|
-
|
|
88
|
-
| Hook | Stop behavior |
|
|
89
|
-
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
90
|
-
| `preLogin` | `loginWithCredentialsCore` / `loginCallback` returns `{ status: false, reason: signal.errorCode }`. No session minted. |
|
|
91
|
-
| `preRegister` | `registerWithCredentials` / `loginCallback` returns the same `{ status: false, reason }`. No user created. |
|
|
92
|
-
| `preLogout` | `logout` warn-logs, emits `socketEventNames.logout, 'error'` to the socket, and returns. The session is NOT deleted. |
|
|
93
|
-
| `preSessionCreate` | `saveSession` warn-logs and returns. The session is NOT written. The active-users set is not updated. |
|
|
94
|
-
| `preSessionDelete` | `deleteSession` warn-logs and returns `false`. The session record stays in storage; no socket gets logged out. |
|
|
95
|
-
| `preSessionRefresh` | The TTL extend is skipped. `getSession` still returns the session — only the sliding-window refresh is gated. |
|
|
96
|
-
|
|
97
|
-
The `errorCode` is propagated to the API caller as the `reason` field (i18n key). For the registers and logins above, custom error codes flow back through the same channel the framework uses for `login.empty` / `login.wrongPassword` / `login.userNotFound`.
|
|
98
|
-
|
|
99
|
-
Multiple handlers run in registration order; the FIRST stop signal wins. Subsequent handlers in the chain do NOT run after a stop:
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
// From core/registry.ts
|
|
103
|
-
for (const handler of handlers) {
|
|
104
|
-
const result = await handler(payload);
|
|
105
|
-
if (result && result.stop) {
|
|
106
|
-
return { stopped: true, signal: result };
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return { stopped: false };
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
If you want every handler to run regardless of stops (audit-only), subscribe to the `post*` variant instead — those don't honor stop signals because the side-effect has already happened.
|
|
113
|
-
|
|
114
|
-
## Payload shapes
|
|
115
|
-
|
|
116
|
-
### `PreLoginPayload`
|
|
117
|
-
|
|
118
|
-
```ts
|
|
119
|
-
{ email: string; provider: string }
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Fired BEFORE the user lookup, password compare, or OAuth find-or-create. `provider` is `'credentials'` for the credentials path or `'google'` / `'github'` / etc. for OAuth. The email is normalized (lowercased + trimmed for credentials; whatever the provider returned for OAuth).
|
|
123
|
-
|
|
124
|
-
### `PostLoginPayload`
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
{ userId: string; provider: string; isNewUser: boolean; token: string }
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Fired AFTER `saveSession` succeeds. `isNewUser` is `false` for credentials login (registration auto-fires `postRegister` separately) and `true` for first-time OAuth users. `token` is the freshly-minted 64-char hex session token.
|
|
131
|
-
|
|
132
|
-
### `PreRegisterPayload`
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
{ email: string; provider: string; name?: string }
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Fired BEFORE `userAdapter.create`. `name` is only present for credentials registration; OAuth users get the display name from the provider profile.
|
|
139
|
-
|
|
140
|
-
### `PostRegisterPayload`
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
{ userId: string; provider: string }
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Fired AFTER `userAdapter.create` succeeds. For OAuth, `postRegister` AND `postLogin` both fire (in that order). For credentials, only `postRegister` fires — the user must follow up with a login call to get `postLogin`.
|
|
147
|
-
|
|
148
|
-
### `PreLogoutPayload` / `PostLogoutPayload`
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
{ userId: string | null; token: string | null }
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Both fields are nullable because `logout` is sometimes invoked on a socket that already lost its session (the cleanup path). Handlers must guard against null:
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
registerHook('postLogout', async ({ userId, token }) => {
|
|
158
|
-
if (!userId || !token) return; // cleanup-only call, nothing to do
|
|
159
|
-
await auditLog(userId);
|
|
160
|
-
});
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
### `PreSessionCreatePayload` / `PostSessionCreatePayload`
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
{ token: string; user: BaseSessionLayout; persistent: boolean }
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
`persistent` is the inverse of `projectConfig.session.basedToken` — `true` means "this session survives a browser restart" (the default), `false` means "session-cookie only". Audit handlers can use this to differentiate a "remember me" login from a one-shot one.
|
|
170
|
-
|
|
171
|
-
`user` is the freshly-sanitized session layout — password already stripped, avatar already resolved. Hooks should treat it as read-only; mutating it does NOT affect the saved session (the framework writes the original reference, but other hooks expect immutability).
|
|
172
|
-
|
|
173
|
-
### `PreSessionDeletePayload` / `PostSessionDeletePayload`
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
{ token: string; userId: string | null }
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
`userId` is `null` when the session record was already gone by the time `deleteSession` read it (race condition, double-delete, expired-but-not-yet-untracked). Audit handlers that "log every session ending" should use the token prefix as the correlation key and treat a null userId as "we don't know who anymore — log it anyway".
|
|
180
|
-
|
|
181
|
-
### `PreSessionRefreshPayload` / `PostSessionRefreshPayload`
|
|
182
|
-
|
|
183
|
-
Declared in `@luckystack/core`:
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
{ token: string; userId: string; oldTtl: number; newTtl: number }
|
|
187
|
-
// postSessionRefresh additionally has: applied: boolean
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
Fired on every authenticated `getSession` — i.e. every API call with a valid session cookie. Be careful with side-effects here; subscribing a write to a slow store on this hook will slow down every request.
|
|
191
|
-
|
|
192
|
-
`applied: boolean` on `postSessionRefresh` reflects whether the TTL was actually extended (true) or skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend).
|
|
193
|
-
|
|
194
|
-
### `PasswordResetRequestedPayload`
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
{
|
|
198
|
-
email: string;
|
|
199
|
-
matched: boolean;
|
|
200
|
-
userId?: string; // only when matched
|
|
201
|
-
token?: string; // only when matched — DO NOT LOG
|
|
202
|
-
ttlSeconds?: number; // only when matched
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
Fires from `sendPasswordResetEmail` once per request — for both matched and unmatched emails. The unmatched case fires with `matched: false` and no `userId` / `token` / `ttlSeconds`. Anti-enumeration: audit handlers MUST NOT differentiate UX between matched and unmatched (don't bounce the email faster on misses).
|
|
207
|
-
|
|
208
|
-
`token` IS included so that audit code can record the reset-token hash for later forensic correlation, but logging the raw token to disk would defeat the whole point of one-time-use tokens. If you need to record it, hash it first:
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
registerHook('passwordResetRequested', async ({ token, userId, matched }) => {
|
|
212
|
-
if (!matched) return;
|
|
213
|
-
await prisma.auditLog.create({
|
|
214
|
-
data: {
|
|
215
|
-
action: 'passwordResetRequested',
|
|
216
|
-
userId,
|
|
217
|
-
tokenHash: createHash('sha256').update(token).digest('hex'),
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
### `PasswordResetCompletedPayload`
|
|
224
|
-
|
|
225
|
-
```ts
|
|
226
|
-
{ userId: string; revokedOtherSessions: boolean }
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
Fired from the consumer-side reset-password completion endpoint AFTER the new password is written and (optionally) other sessions are revoked. `revokedOtherSessions` reflects whether the API called `revokeUserSessions(userId)` on completion — the bundled template does this; custom completion endpoints may not.
|
|
230
|
-
|
|
231
|
-
### `PasswordChangedPayload`
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
{ userId: string; verifiedCurrent: boolean; revokedOtherSessions: boolean }
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Fired from the in-session "change my password" endpoint. `verifiedCurrent` is `true` when the user provided their current password correctly. Some flows allow admins to set a user's password without verification (`verifiedCurrent: false`); audit handlers should branch on this to emit a different log line for admin-initiated changes.
|
|
238
|
-
|
|
239
|
-
## Dispatch order around a successful login
|
|
240
|
-
|
|
241
|
-
```
|
|
242
|
-
preLogin
|
|
243
|
-
├─ stop? → return { status: false, reason: signal.errorCode }
|
|
244
|
-
└─ continue
|
|
245
|
-
[mint token, build session]
|
|
246
|
-
preSessionCreate
|
|
247
|
-
├─ stop? → return without saving
|
|
248
|
-
└─ continue
|
|
249
|
-
[adapter.setRaw + trackActive + single-session enforcement + broadcast]
|
|
250
|
-
postSessionCreate
|
|
251
|
-
postLogin
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Around a successful new-OAuth-user signup the sequence is:
|
|
255
|
-
|
|
256
|
-
```
|
|
257
|
-
preLogin
|
|
258
|
-
├─ stop? → return false
|
|
259
|
-
└─ continue
|
|
260
|
-
[provider userinfo fetch]
|
|
261
|
-
preRegister
|
|
262
|
-
├─ stop? → return false
|
|
263
|
-
└─ continue
|
|
264
|
-
[userAdapter.create]
|
|
265
|
-
preSessionCreate → postSessionCreate (around saveSession)
|
|
266
|
-
postRegister
|
|
267
|
-
postLogin
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
`postRegister` fires BEFORE `postLogin` for new OAuth users so audit handlers that "send a welcome email" don't race with a "login successful" notification.
|
|
271
|
-
|
|
272
|
-
## Dispatch order around logout
|
|
273
|
-
|
|
274
|
-
```
|
|
275
|
-
preLogout
|
|
276
|
-
├─ stop? → warn-log + socket.emit(logout, 'error') + return
|
|
277
|
-
└─ continue
|
|
278
|
-
[deleteSession (which fires preSessionDelete / postSessionDelete internally)]
|
|
279
|
-
├─ stop from preSessionDelete? → warn-log + return false
|
|
280
|
-
└─ continue
|
|
281
|
-
[srem activeUsers, socket.leave(token)]
|
|
282
|
-
postLogout
|
|
283
|
-
[socket.emit(logout, 'success')]
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
The `postLogout` dispatch happens BEFORE the socket emit because subscribers may want to push a final message to the socket before the client clears its sessionStorage. After the emit, the client redirects to `/login` and stops listening.
|
|
287
|
-
|
|
288
|
-
## Payload-not-mutated rule
|
|
289
|
-
|
|
290
|
-
Handlers must NOT mutate the payload object. The framework passes the same reference to every handler in the chain — mutating it from handler #1 would surprise handler #2.
|
|
291
|
-
|
|
292
|
-
If you need to attach extra state during login (a tenant id, a feature flag, an enrichment that another package wants to read), write it into your own key space using ids from the payload:
|
|
293
|
-
|
|
294
|
-
```ts
|
|
295
|
-
// Bad — mutates payload
|
|
296
|
-
registerHook('preLogin', async (payload) => {
|
|
297
|
-
payload.email = payload.email.toLowerCase(); // already done!
|
|
298
|
-
(payload as any).tenant = await resolveTenant(payload.email);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// Good — write to your own store
|
|
302
|
-
registerHook('preLogin', async ({ email }) => {
|
|
303
|
-
const tenant = await resolveTenant(email);
|
|
304
|
-
await redis.set(`tenant-context:${email}`, tenant.id, 'EX', 300);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
registerHook('postLogin', async ({ userId, token }) => {
|
|
308
|
-
const tenant = await redis.get(`tenant-context:somehow`);
|
|
309
|
-
// ...wire it to the user's session
|
|
310
|
-
});
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
For OAuth providers specifically, the dedicated `extraSessionFields` callback (see [`./oauth-providers.md`](./oauth-providers.md)) is the supported way to add per-session extras — it runs after the userinfo fetch and the merged record is broadcast to the client via the session.
|
|
314
|
-
|
|
315
|
-
## Subscribing — `registerHook` from `@luckystack/core`
|
|
316
|
-
|
|
317
|
-
```ts
|
|
318
|
-
import { registerHook } from '@luckystack/core';
|
|
319
|
-
|
|
320
|
-
registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
|
|
321
|
-
// ...
|
|
322
|
-
});
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
Subscriptions are global per Node.js process. Order is registration-order; there is no priority system. Inside a single package, group hook registrations into a single boot file so the order is obvious at a glance.
|
|
326
|
-
|
|
327
|
-
`registerHook` is fire-and-add: there is no `unregisterHook`. Subscriptions live for the lifetime of the process. For test isolation, call `clearAllHooks()` (exported from `@luckystack/core`) in your test setup.
|
|
328
|
-
|
|
329
|
-
## Sentinel values to watch
|
|
330
|
-
|
|
331
|
-
- `PreLogoutPayload.userId` / `.token` are NULLABLE. Cleanup-path logouts (e.g. socket disconnect after a session expired between writes) fire `preLogout` with both fields null. Handlers must guard.
|
|
332
|
-
- `PreSessionDeletePayload.userId` is nullable for the same race-condition reason. Handlers that "log every session deletion" should fall back to the token prefix.
|
|
333
|
-
- `PasswordResetRequestedPayload.userId` / `.token` are absent when `matched === false`. Use the `matched` flag as the discriminator; do NOT key on truthy `userId` alone.
|
|
334
|
-
|
|
335
|
-
## Audit handler patterns
|
|
336
|
-
|
|
337
|
-
```ts
|
|
338
|
-
// Per-user "last login" history table (audit-grade vs the best-effort User.lastLogin column)
|
|
339
|
-
registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
|
|
340
|
-
await prisma.loginEvent.create({
|
|
341
|
-
data: {
|
|
342
|
-
userId,
|
|
343
|
-
provider,
|
|
344
|
-
isNewUser,
|
|
345
|
-
tokenPrefix: token.slice(0, 8),
|
|
346
|
-
timestamp: new Date(),
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
// Notify on suspicious sign-in (IP change)
|
|
352
|
-
registerHook('postLogin', async ({ userId }) => {
|
|
353
|
-
const ip = currentRequestIp(); // your own AsyncLocalStorage helper
|
|
354
|
-
const last = await prisma.user.findUnique({ where: { id: userId }, select: { lastIp: true } });
|
|
355
|
-
if (last?.lastIp && last.lastIp !== ip) {
|
|
356
|
-
await notifyAccountOwner(userId, ip);
|
|
357
|
-
}
|
|
358
|
-
await prisma.user.update({ where: { id: userId }, data: { lastIp: ip } });
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// Block unverified users at the gate
|
|
362
|
-
registerHook('preLogin', async ({ email, provider }) => {
|
|
363
|
-
if (provider !== 'credentials') return;
|
|
364
|
-
const user = await prisma.user.findFirst({ where: { email } });
|
|
365
|
-
if (user && !user.verified) {
|
|
366
|
-
return { stop: true, errorCode: 'login.notVerified' };
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// Tear down per-user caches on logout
|
|
371
|
-
registerHook('postLogout', async ({ userId }) => {
|
|
372
|
-
if (!userId) return;
|
|
373
|
-
cache.del(`user-prefs:${userId}`);
|
|
374
|
-
cache.del(`user-feed:${userId}`);
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
// Mirror new OAuth users into a CRM (postRegister fires BEFORE postLogin for new OAuth users)
|
|
378
|
-
registerHook('postRegister', async ({ userId, provider }) => {
|
|
379
|
-
await crmClient.createContact({ externalId: userId, source: provider });
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
// Audit every session deletion with full stacktrace correlation
|
|
383
|
-
registerHook('postSessionDelete', async ({ token, userId }) => {
|
|
384
|
-
await prisma.auditLog.create({
|
|
385
|
-
data: {
|
|
386
|
-
action: 'sessionDelete',
|
|
387
|
-
userId, // may be null
|
|
388
|
-
tokenPrefix: token.slice(0, 8),
|
|
389
|
-
timestamp: new Date(),
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// Block logout while a critical operation is in flight
|
|
395
|
-
registerHook('preLogout', async ({ userId }) => {
|
|
396
|
-
if (!userId) return;
|
|
397
|
-
const pending = await prisma.pendingTransaction.count({ where: { userId } });
|
|
398
|
-
if (pending > 0) {
|
|
399
|
-
return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Audit password resets (token hashed before logging)
|
|
404
|
-
registerHook('passwordResetRequested', async ({ email, matched, userId, token }) => {
|
|
405
|
-
if (!matched) {
|
|
406
|
-
await prisma.auditLog.create({
|
|
407
|
-
data: { action: 'passwordResetMissed', email, timestamp: new Date() },
|
|
408
|
-
});
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
await prisma.auditLog.create({
|
|
412
|
-
data: {
|
|
413
|
-
action: 'passwordResetRequested',
|
|
414
|
-
userId,
|
|
415
|
-
tokenHash: createHash('sha256').update(token!).digest('hex'),
|
|
416
|
-
timestamp: new Date(),
|
|
417
|
-
},
|
|
418
|
-
});
|
|
419
|
-
});
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
## Why no `preLogin` mutate-payload escape hatch
|
|
423
|
-
|
|
424
|
-
A common request is "let `preLogin` set the user's tenant on the session before save". The framework deliberately rejects that pattern:
|
|
425
|
-
|
|
426
|
-
- It would couple the auth contract to every consumer's tenant model.
|
|
427
|
-
- It would let a misbehaving handler nuke the session shape (drop the userId, change the email).
|
|
428
|
-
- Multiple handlers writing the same field race each other.
|
|
429
|
-
|
|
430
|
-
Use one of the supported extension points instead:
|
|
431
|
-
|
|
432
|
-
- **Per-provider OAuth runtime extras** — `extraSessionFields` on the provider definition.
|
|
433
|
-
- **Custom session field, hook-populated** — augment `BaseSessionLayout` via module declaration, then write the field inside `postSessionCreate` via `saveSession(token, { ...user, tenantId }, false)`. The framework treats the second write as an update.
|
|
434
|
-
- **Out-of-band store** — write to your own Redis namespace and look up by `session.id` on the consumer side.
|
|
435
|
-
|
|
436
|
-
## Related
|
|
437
|
-
|
|
438
|
-
- [`./credentials-auth.md`](./credentials-auth.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` dispatch sites.
|
|
439
|
-
- [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and OAuth-specific lifecycle.
|
|
440
|
-
- [`./logout-flow.md`](./logout-flow.md) — `preLogout` / `postLogout` and the spurious-logout warn-log.
|
|
441
|
-
- [`./session-management.md`](./session-management.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
|
|
442
|
-
- [`./password-reset.md`](./password-reset.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged`.
|
|
443
|
-
- Core hook bus: [`/packages/core/docs/hooks.md`](../../core/docs/hooks.md).
|
|
444
|
-
- Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
|
|
1
|
+
# Lifecycle hooks
|
|
2
|
+
|
|
3
|
+
Deep-dive on the authentication and session lifecycle hooks that `@luckystack/login` declares, fires, and lets consumers subscribe to. The hook bus itself lives in `@luckystack/core` (`registerHook` / `dispatchHook`); this package extends the core's `HookPayloads` interface via TypeScript module augmentation and dispatches against it from `login.ts`, `logout.ts`, `session.ts`, and `forgotPassword.ts`.
|
|
4
|
+
|
|
5
|
+
Canonical sources:
|
|
6
|
+
|
|
7
|
+
- Payload types: [`./src/hookPayloads.ts`](../src/hookPayloads.ts)
|
|
8
|
+
- Dispatch sites: [`./src/login.ts`](../src/login.ts), [`./src/logout.ts`](../src/logout.ts), [`./src/session.ts`](../src/session.ts), [`./src/forgotPassword.ts`](../src/forgotPassword.ts)
|
|
9
|
+
- Bus implementation: [`@luckystack/core/src/hooks/registry.ts`](../../core/src/hooks/registry.ts)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Module augmentation
|
|
14
|
+
|
|
15
|
+
`@luckystack/login` does not define a parallel hook bus. It augments the shared `HookPayloads` interface that `@luckystack/core` exposes:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// packages/login/src/hookPayloads.ts
|
|
19
|
+
declare module '@luckystack/core' {
|
|
20
|
+
interface HookPayloads {
|
|
21
|
+
preLogin: PreLoginPayload;
|
|
22
|
+
postLogin: PostLoginPayload;
|
|
23
|
+
preRegister: PreRegisterPayload;
|
|
24
|
+
postRegister: PostRegisterPayload;
|
|
25
|
+
preLogout: PreLogoutPayload;
|
|
26
|
+
postLogout: PostLogoutPayload;
|
|
27
|
+
preSessionCreate: PreSessionCreatePayload;
|
|
28
|
+
postSessionCreate: PostSessionCreatePayload;
|
|
29
|
+
preSessionDelete: PreSessionDeletePayload;
|
|
30
|
+
postSessionDelete: PostSessionDeletePayload;
|
|
31
|
+
passwordResetRequested: PasswordResetRequestedPayload;
|
|
32
|
+
passwordResetCompleted: PasswordResetCompletedPayload;
|
|
33
|
+
passwordChanged: PasswordChangedPayload;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This module is referenced from `index.ts` as a side-effect import:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import './hookPayloads';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The side-effect import has no runtime cost (the file only contains type declarations) but makes the dependency explicit for readers and ensures the augmentation lands in any TypeScript program that imports `@luckystack/login` — without the side-effect import, projects with strict tree-shaking on type-only imports could lose the declaration.
|
|
45
|
+
|
|
46
|
+
`preSessionRefresh` / `postSessionRefresh` are owned by `@luckystack/core` (declared in `packages/core/src/hooks/types.ts`) but dispatched from this package's `getSession`. They are listed here because subscribing to them from `@luckystack/login`-aware code is identical to the auth hooks.
|
|
47
|
+
|
|
48
|
+
## Hook table
|
|
49
|
+
|
|
50
|
+
| Hook | Aborts on stop signal | Fires from | Async? |
|
|
51
|
+
| -------------------------- | ----------------------------------- | ---------------------------------------------------------------- | ------ |
|
|
52
|
+
| `preLogin` | Yes — login refused with `errorCode` | `loginWithCredentialsCore`, `loginCallback` | Yes |
|
|
53
|
+
| `postLogin` | No — `void` return | `loginWithCredentialsCore` (success), `loginCallback` (success) | Yes |
|
|
54
|
+
| `preRegister` | Yes — register refused | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
|
|
55
|
+
| `postRegister` | No | `registerWithCredentials`, `loginCallback` (new OAuth user) | Yes |
|
|
56
|
+
| `preLogout` | Yes — logout aborted, socket gets `'error'` | `logout` | Yes |
|
|
57
|
+
| `postLogout` | No | `logout` (success) | Yes |
|
|
58
|
+
| `preSessionCreate` | Yes — save skipped | `saveSession(token, user, true)` | Yes |
|
|
59
|
+
| `postSessionCreate` | No | `saveSession(token, user, true)` (success) | Yes |
|
|
60
|
+
| `preSessionDelete` | Yes — delete refused, returns `false` | `deleteSession` | Yes |
|
|
61
|
+
| `postSessionDelete` | No | `deleteSession` (success) | Yes |
|
|
62
|
+
| `preSessionRefresh` | Yes — TTL extend skipped | `getSession` (every authenticated read) | Yes |
|
|
63
|
+
| `postSessionRefresh` | No | `getSession` (every authenticated read) | Yes |
|
|
64
|
+
| `passwordResetRequested` | No — fired for matched AND unmatched | `sendPasswordResetEmail` | Yes (fire-and-forget via `void dispatchHook(...)`) |
|
|
65
|
+
| `passwordResetCompleted` | No | The consumer-side reset-password completion API | Yes |
|
|
66
|
+
| `passwordChanged` | No | The consumer-side in-session password-change API | Yes |
|
|
67
|
+
|
|
68
|
+
`passwordResetCompleted` and `passwordChanged` are declared by this package but dispatched by the installer-side `reset-password/_api/*` and `settings/_api/changePassword_v1` routes — see the `create-luckystack-app` template. They live here because the payload shape is part of the auth contract.
|
|
69
|
+
|
|
70
|
+
## Stop-signal contract
|
|
71
|
+
|
|
72
|
+
`pre*` hooks can abort the operation by returning a `HookStopSignal`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { registerHook } from '@luckystack/core';
|
|
76
|
+
|
|
77
|
+
registerHook('preLogin', async ({ email, provider }) => {
|
|
78
|
+
if (provider !== 'credentials') return;
|
|
79
|
+
const user = await prisma.user.findFirst({ where: { email } });
|
|
80
|
+
if (user && !user.verified) {
|
|
81
|
+
return { stop: true, errorCode: 'login.notVerified' };
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
What "stop" means depends on the hook:
|
|
87
|
+
|
|
88
|
+
| Hook | Stop behavior |
|
|
89
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
90
|
+
| `preLogin` | `loginWithCredentialsCore` / `loginCallback` returns `{ status: false, reason: signal.errorCode }`. No session minted. |
|
|
91
|
+
| `preRegister` | `registerWithCredentials` / `loginCallback` returns the same `{ status: false, reason }`. No user created. |
|
|
92
|
+
| `preLogout` | `logout` warn-logs, emits `socketEventNames.logout, 'error'` to the socket, and returns. The session is NOT deleted. |
|
|
93
|
+
| `preSessionCreate` | `saveSession` warn-logs and returns. The session is NOT written. The active-users set is not updated. |
|
|
94
|
+
| `preSessionDelete` | `deleteSession` warn-logs and returns `false`. The session record stays in storage; no socket gets logged out. |
|
|
95
|
+
| `preSessionRefresh` | The TTL extend is skipped. `getSession` still returns the session — only the sliding-window refresh is gated. |
|
|
96
|
+
|
|
97
|
+
The `errorCode` is propagated to the API caller as the `reason` field (i18n key). For the registers and logins above, custom error codes flow back through the same channel the framework uses for `login.empty` / `login.wrongPassword` / `login.userNotFound`.
|
|
98
|
+
|
|
99
|
+
Multiple handlers run in registration order; the FIRST stop signal wins. Subsequent handlers in the chain do NOT run after a stop:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// From core/registry.ts
|
|
103
|
+
for (const handler of handlers) {
|
|
104
|
+
const result = await handler(payload);
|
|
105
|
+
if (result && result.stop) {
|
|
106
|
+
return { stopped: true, signal: result };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { stopped: false };
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
If you want every handler to run regardless of stops (audit-only), subscribe to the `post*` variant instead — those don't honor stop signals because the side-effect has already happened.
|
|
113
|
+
|
|
114
|
+
## Payload shapes
|
|
115
|
+
|
|
116
|
+
### `PreLoginPayload`
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
{ email: string; provider: string }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Fired BEFORE the user lookup, password compare, or OAuth find-or-create. `provider` is `'credentials'` for the credentials path or `'google'` / `'github'` / etc. for OAuth. The email is normalized (lowercased + trimmed for credentials; whatever the provider returned for OAuth).
|
|
123
|
+
|
|
124
|
+
### `PostLoginPayload`
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
{ userId: string; provider: string; isNewUser: boolean; token: string }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Fired AFTER `saveSession` succeeds. `isNewUser` is `false` for credentials login (registration auto-fires `postRegister` separately) and `true` for first-time OAuth users. `token` is the freshly-minted 64-char hex session token.
|
|
131
|
+
|
|
132
|
+
### `PreRegisterPayload`
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
{ email: string; provider: string; name?: string }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Fired BEFORE `userAdapter.create`. `name` is only present for credentials registration; OAuth users get the display name from the provider profile.
|
|
139
|
+
|
|
140
|
+
### `PostRegisterPayload`
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
{ userId: string; provider: string }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Fired AFTER `userAdapter.create` succeeds. For OAuth, `postRegister` AND `postLogin` both fire (in that order). For credentials, only `postRegister` fires — the user must follow up with a login call to get `postLogin`.
|
|
147
|
+
|
|
148
|
+
### `PreLogoutPayload` / `PostLogoutPayload`
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
{ userId: string | null; token: string | null }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Both fields are nullable because `logout` is sometimes invoked on a socket that already lost its session (the cleanup path). Handlers must guard against null:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
registerHook('postLogout', async ({ userId, token }) => {
|
|
158
|
+
if (!userId || !token) return; // cleanup-only call, nothing to do
|
|
159
|
+
await auditLog(userId);
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `PreSessionCreatePayload` / `PostSessionCreatePayload`
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
{ token: string; user: BaseSessionLayout; persistent: boolean }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`persistent` is the inverse of `projectConfig.session.basedToken` — `true` means "this session survives a browser restart" (the default), `false` means "session-cookie only". Audit handlers can use this to differentiate a "remember me" login from a one-shot one.
|
|
170
|
+
|
|
171
|
+
`user` is the freshly-sanitized session layout — password already stripped, avatar already resolved. Hooks should treat it as read-only; mutating it does NOT affect the saved session (the framework writes the original reference, but other hooks expect immutability).
|
|
172
|
+
|
|
173
|
+
### `PreSessionDeletePayload` / `PostSessionDeletePayload`
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
{ token: string; userId: string | null }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`userId` is `null` when the session record was already gone by the time `deleteSession` read it (race condition, double-delete, expired-but-not-yet-untracked). Audit handlers that "log every session ending" should use the token prefix as the correlation key and treat a null userId as "we don't know who anymore — log it anyway".
|
|
180
|
+
|
|
181
|
+
### `PreSessionRefreshPayload` / `PostSessionRefreshPayload`
|
|
182
|
+
|
|
183
|
+
Declared in `@luckystack/core`:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
{ token: string; userId: string; oldTtl: number; newTtl: number }
|
|
187
|
+
// postSessionRefresh additionally has: applied: boolean
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Fired on every authenticated `getSession` — i.e. every API call with a valid session cookie. Be careful with side-effects here; subscribing a write to a slow store on this hook will slow down every request.
|
|
191
|
+
|
|
192
|
+
`applied: boolean` on `postSessionRefresh` reflects whether the TTL was actually extended (true) or skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend).
|
|
193
|
+
|
|
194
|
+
### `PasswordResetRequestedPayload`
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
{
|
|
198
|
+
email: string;
|
|
199
|
+
matched: boolean;
|
|
200
|
+
userId?: string; // only when matched
|
|
201
|
+
token?: string; // only when matched — DO NOT LOG
|
|
202
|
+
ttlSeconds?: number; // only when matched
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Fires from `sendPasswordResetEmail` once per request — for both matched and unmatched emails. The unmatched case fires with `matched: false` and no `userId` / `token` / `ttlSeconds`. Anti-enumeration: audit handlers MUST NOT differentiate UX between matched and unmatched (don't bounce the email faster on misses).
|
|
207
|
+
|
|
208
|
+
`token` IS included so that audit code can record the reset-token hash for later forensic correlation, but logging the raw token to disk would defeat the whole point of one-time-use tokens. If you need to record it, hash it first:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
registerHook('passwordResetRequested', async ({ token, userId, matched }) => {
|
|
212
|
+
if (!matched) return;
|
|
213
|
+
await prisma.auditLog.create({
|
|
214
|
+
data: {
|
|
215
|
+
action: 'passwordResetRequested',
|
|
216
|
+
userId,
|
|
217
|
+
tokenHash: createHash('sha256').update(token).digest('hex'),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### `PasswordResetCompletedPayload`
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
{ userId: string; revokedOtherSessions: boolean }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Fired from the consumer-side reset-password completion endpoint AFTER the new password is written and (optionally) other sessions are revoked. `revokedOtherSessions` reflects whether the API called `revokeUserSessions(userId)` on completion — the bundled template does this; custom completion endpoints may not.
|
|
230
|
+
|
|
231
|
+
### `PasswordChangedPayload`
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
{ userId: string; verifiedCurrent: boolean; revokedOtherSessions: boolean }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Fired from the in-session "change my password" endpoint. `verifiedCurrent` is `true` when the user provided their current password correctly. Some flows allow admins to set a user's password without verification (`verifiedCurrent: false`); audit handlers should branch on this to emit a different log line for admin-initiated changes.
|
|
238
|
+
|
|
239
|
+
## Dispatch order around a successful login
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
preLogin
|
|
243
|
+
├─ stop? → return { status: false, reason: signal.errorCode }
|
|
244
|
+
└─ continue
|
|
245
|
+
[mint token, build session]
|
|
246
|
+
preSessionCreate
|
|
247
|
+
├─ stop? → return without saving
|
|
248
|
+
└─ continue
|
|
249
|
+
[adapter.setRaw + trackActive + single-session enforcement + broadcast]
|
|
250
|
+
postSessionCreate
|
|
251
|
+
postLogin
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Around a successful new-OAuth-user signup the sequence is:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
preLogin
|
|
258
|
+
├─ stop? → return false
|
|
259
|
+
└─ continue
|
|
260
|
+
[provider userinfo fetch]
|
|
261
|
+
preRegister
|
|
262
|
+
├─ stop? → return false
|
|
263
|
+
└─ continue
|
|
264
|
+
[userAdapter.create]
|
|
265
|
+
preSessionCreate → postSessionCreate (around saveSession)
|
|
266
|
+
postRegister
|
|
267
|
+
postLogin
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`postRegister` fires BEFORE `postLogin` for new OAuth users so audit handlers that "send a welcome email" don't race with a "login successful" notification.
|
|
271
|
+
|
|
272
|
+
## Dispatch order around logout
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
preLogout
|
|
276
|
+
├─ stop? → warn-log + socket.emit(logout, 'error') + return
|
|
277
|
+
└─ continue
|
|
278
|
+
[deleteSession (which fires preSessionDelete / postSessionDelete internally)]
|
|
279
|
+
├─ stop from preSessionDelete? → warn-log + return false
|
|
280
|
+
└─ continue
|
|
281
|
+
[srem activeUsers, socket.leave(token)]
|
|
282
|
+
postLogout
|
|
283
|
+
[socket.emit(logout, 'success')]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The `postLogout` dispatch happens BEFORE the socket emit because subscribers may want to push a final message to the socket before the client clears its sessionStorage. After the emit, the client redirects to `/login` and stops listening.
|
|
287
|
+
|
|
288
|
+
## Payload-not-mutated rule
|
|
289
|
+
|
|
290
|
+
Handlers must NOT mutate the payload object. The framework passes the same reference to every handler in the chain — mutating it from handler #1 would surprise handler #2.
|
|
291
|
+
|
|
292
|
+
If you need to attach extra state during login (a tenant id, a feature flag, an enrichment that another package wants to read), write it into your own key space using ids from the payload:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
// Bad — mutates payload
|
|
296
|
+
registerHook('preLogin', async (payload) => {
|
|
297
|
+
payload.email = payload.email.toLowerCase(); // already done!
|
|
298
|
+
(payload as any).tenant = await resolveTenant(payload.email);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Good — write to your own store
|
|
302
|
+
registerHook('preLogin', async ({ email }) => {
|
|
303
|
+
const tenant = await resolveTenant(email);
|
|
304
|
+
await redis.set(`tenant-context:${email}`, tenant.id, 'EX', 300);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
registerHook('postLogin', async ({ userId, token }) => {
|
|
308
|
+
const tenant = await redis.get(`tenant-context:somehow`);
|
|
309
|
+
// ...wire it to the user's session
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
For OAuth providers specifically, the dedicated `extraSessionFields` callback (see [`./oauth-providers.md`](./oauth-providers.md)) is the supported way to add per-session extras — it runs after the userinfo fetch and the merged record is broadcast to the client via the session.
|
|
314
|
+
|
|
315
|
+
## Subscribing — `registerHook` from `@luckystack/core`
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import { registerHook } from '@luckystack/core';
|
|
319
|
+
|
|
320
|
+
registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
|
|
321
|
+
// ...
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Subscriptions are global per Node.js process. Order is registration-order; there is no priority system. Inside a single package, group hook registrations into a single boot file so the order is obvious at a glance.
|
|
326
|
+
|
|
327
|
+
`registerHook` is fire-and-add: there is no `unregisterHook`. Subscriptions live for the lifetime of the process. For test isolation, call `clearAllHooks()` (exported from `@luckystack/core`) in your test setup.
|
|
328
|
+
|
|
329
|
+
## Sentinel values to watch
|
|
330
|
+
|
|
331
|
+
- `PreLogoutPayload.userId` / `.token` are NULLABLE. Cleanup-path logouts (e.g. socket disconnect after a session expired between writes) fire `preLogout` with both fields null. Handlers must guard.
|
|
332
|
+
- `PreSessionDeletePayload.userId` is nullable for the same race-condition reason. Handlers that "log every session deletion" should fall back to the token prefix.
|
|
333
|
+
- `PasswordResetRequestedPayload.userId` / `.token` are absent when `matched === false`. Use the `matched` flag as the discriminator; do NOT key on truthy `userId` alone.
|
|
334
|
+
|
|
335
|
+
## Audit handler patterns
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// Per-user "last login" history table (audit-grade vs the best-effort User.lastLogin column)
|
|
339
|
+
registerHook('postLogin', async ({ userId, provider, isNewUser, token }) => {
|
|
340
|
+
await prisma.loginEvent.create({
|
|
341
|
+
data: {
|
|
342
|
+
userId,
|
|
343
|
+
provider,
|
|
344
|
+
isNewUser,
|
|
345
|
+
tokenPrefix: token.slice(0, 8),
|
|
346
|
+
timestamp: new Date(),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Notify on suspicious sign-in (IP change)
|
|
352
|
+
registerHook('postLogin', async ({ userId }) => {
|
|
353
|
+
const ip = currentRequestIp(); // your own AsyncLocalStorage helper
|
|
354
|
+
const last = await prisma.user.findUnique({ where: { id: userId }, select: { lastIp: true } });
|
|
355
|
+
if (last?.lastIp && last.lastIp !== ip) {
|
|
356
|
+
await notifyAccountOwner(userId, ip);
|
|
357
|
+
}
|
|
358
|
+
await prisma.user.update({ where: { id: userId }, data: { lastIp: ip } });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Block unverified users at the gate
|
|
362
|
+
registerHook('preLogin', async ({ email, provider }) => {
|
|
363
|
+
if (provider !== 'credentials') return;
|
|
364
|
+
const user = await prisma.user.findFirst({ where: { email } });
|
|
365
|
+
if (user && !user.verified) {
|
|
366
|
+
return { stop: true, errorCode: 'login.notVerified' };
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Tear down per-user caches on logout
|
|
371
|
+
registerHook('postLogout', async ({ userId }) => {
|
|
372
|
+
if (!userId) return;
|
|
373
|
+
cache.del(`user-prefs:${userId}`);
|
|
374
|
+
cache.del(`user-feed:${userId}`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Mirror new OAuth users into a CRM (postRegister fires BEFORE postLogin for new OAuth users)
|
|
378
|
+
registerHook('postRegister', async ({ userId, provider }) => {
|
|
379
|
+
await crmClient.createContact({ externalId: userId, source: provider });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Audit every session deletion with full stacktrace correlation
|
|
383
|
+
registerHook('postSessionDelete', async ({ token, userId }) => {
|
|
384
|
+
await prisma.auditLog.create({
|
|
385
|
+
data: {
|
|
386
|
+
action: 'sessionDelete',
|
|
387
|
+
userId, // may be null
|
|
388
|
+
tokenPrefix: token.slice(0, 8),
|
|
389
|
+
timestamp: new Date(),
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Block logout while a critical operation is in flight
|
|
395
|
+
registerHook('preLogout', async ({ userId }) => {
|
|
396
|
+
if (!userId) return;
|
|
397
|
+
const pending = await prisma.pendingTransaction.count({ where: { userId } });
|
|
398
|
+
if (pending > 0) {
|
|
399
|
+
return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Audit password resets (token hashed before logging)
|
|
404
|
+
registerHook('passwordResetRequested', async ({ email, matched, userId, token }) => {
|
|
405
|
+
if (!matched) {
|
|
406
|
+
await prisma.auditLog.create({
|
|
407
|
+
data: { action: 'passwordResetMissed', email, timestamp: new Date() },
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await prisma.auditLog.create({
|
|
412
|
+
data: {
|
|
413
|
+
action: 'passwordResetRequested',
|
|
414
|
+
userId,
|
|
415
|
+
tokenHash: createHash('sha256').update(token!).digest('hex'),
|
|
416
|
+
timestamp: new Date(),
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Why no `preLogin` mutate-payload escape hatch
|
|
423
|
+
|
|
424
|
+
A common request is "let `preLogin` set the user's tenant on the session before save". The framework deliberately rejects that pattern:
|
|
425
|
+
|
|
426
|
+
- It would couple the auth contract to every consumer's tenant model.
|
|
427
|
+
- It would let a misbehaving handler nuke the session shape (drop the userId, change the email).
|
|
428
|
+
- Multiple handlers writing the same field race each other.
|
|
429
|
+
|
|
430
|
+
Use one of the supported extension points instead:
|
|
431
|
+
|
|
432
|
+
- **Per-provider OAuth runtime extras** — `extraSessionFields` on the provider definition.
|
|
433
|
+
- **Custom session field, hook-populated** — augment `BaseSessionLayout` via module declaration, then write the field inside `postSessionCreate` via `saveSession(token, { ...user, tenantId }, false)`. The framework treats the second write as an update.
|
|
434
|
+
- **Out-of-band store** — write to your own Redis namespace and look up by `session.id` on the consumer side.
|
|
435
|
+
|
|
436
|
+
## Related
|
|
437
|
+
|
|
438
|
+
- [`./credentials-auth.md`](./credentials-auth.md) — `preLogin` / `postLogin` / `preRegister` / `postRegister` dispatch sites.
|
|
439
|
+
- [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and OAuth-specific lifecycle.
|
|
440
|
+
- [`./logout-flow.md`](./logout-flow.md) — `preLogout` / `postLogout` and the spurious-logout warn-log.
|
|
441
|
+
- [`./session-management.md`](./session-management.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
|
|
442
|
+
- [`./password-reset.md`](./password-reset.md) — `passwordResetRequested` / `passwordResetCompleted` / `passwordChanged`.
|
|
443
|
+
- Core hook bus: [`/packages/core/docs/hooks.md`](../../core/docs/hooks.md).
|
|
444
|
+
- Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
|