@luckystack/login 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/hooks.md CHANGED
@@ -1,444 +1,5 @@
1
- # Lifecycle hooks
1
+ # Login lifecycle hooks
2
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`.
3
+ Pre-operation hooks allow a deliberate veto before a login, registration, logout or session mutation. Observational post hooks must not be mistaken for authorization gates or a way to undo completed writes. Handlers see shared payload objects; do not mutate them to control a later handler. Source: `../src/hookPayloads.ts`, `../src/login.ts`, `../src/logout.ts`, `../../core/src/hooks/registry.ts`.
4
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 cookie mode (the credential can survive a browser restart according to cookie expiry, and is shared across tabs); `false` means tab-scoped `sessionStorage` bearer mode. 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).
5
+ Audit handlers must not log tokens, hashes or sensitive reset links. An unmatched reset request should produce the same visible outcome as a matched one; adding a hook must not reintroduce account enumeration. Cleanup-path logout may have no resolved user, so handlers must tolerate an absent identity. See `../src/forgotPassword.ts`, `./password-reset.md`.