@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/logout-flow.md
CHANGED
|
@@ -1,235 +1,235 @@
|
|
|
1
|
-
# Logout flow
|
|
2
|
-
|
|
3
|
-
Deep-dive on `logout` and how it interacts with sockets, sessions, hooks, and presence. Canonical source: [`./src/logout.ts`](../src/logout.ts), with cross-cutting flow in [`./src/session.ts`](../src/session.ts).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Entry point
|
|
8
|
-
|
|
9
|
-
```ts
|
|
10
|
-
logout({
|
|
11
|
-
token: string | null,
|
|
12
|
-
socket: Socket | undefined,
|
|
13
|
-
userId: string | null,
|
|
14
|
-
skipSessionDelete?: boolean,
|
|
15
|
-
}): Promise<void>;
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
All four arguments are nullable / optional because `logout` is called from three different contexts that each know about different subsets of state:
|
|
19
|
-
|
|
20
|
-
1. **The socket's `logout` event handler** — has the socket and token; resolves `userId` from the session lookup.
|
|
21
|
-
2. **`deleteSession`** — has the token and (after a session read) the userId; needs `skipSessionDelete: true` to avoid recursion.
|
|
22
|
-
3. **Single-session enforcement inside `saveSession`** — has the previous token + socket + userId, calls `logout` to disconnect the old device when the new login arrives.
|
|
23
|
-
|
|
24
|
-
Missing arguments cause the function to warn-log and return early without mutating state. Specifically:
|
|
25
|
-
|
|
26
|
-
- No `socket` → `getLogger().warn('logout: invalid socket')` and return. The session is NOT deleted in this case — there's nothing to emit to, but more importantly, "no socket" is an unexpected state and silently destroying the session would hide the bug.
|
|
27
|
-
- No `token` → `getLogger().warn('logout: no token provided')` and return.
|
|
28
|
-
|
|
29
|
-
If you need to programmatically log out a user without a live socket, call `deleteSession(token)` directly. `logout` is the socket-side wrapper.
|
|
30
|
-
|
|
31
|
-
## Sequence
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
preLogout (can stop) → [deleteSession] → srem activeUsers → socket.leave(token) → postLogout → socket.emit(logout, "success")
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Step by step, mirroring the source:
|
|
38
|
-
|
|
39
|
-
### 1. `preLogout` dispatch
|
|
40
|
-
|
|
41
|
-
```ts
|
|
42
|
-
const preLogoutResult = await dispatchHook('preLogout', { userId, token });
|
|
43
|
-
if (preLogoutResult.stopped) {
|
|
44
|
-
getLogger().warn('logout: aborted by preLogout hook', {
|
|
45
|
-
errorCode: preLogoutResult.signal.errorCode,
|
|
46
|
-
});
|
|
47
|
-
socket.emit(socketEventNames.logout, 'error');
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Payload: `{ userId: string | null, token: string | null }`. Both are nullable because logout is sometimes invoked on a socket that already lost its session (cleanup path).
|
|
53
|
-
|
|
54
|
-
A handler can return `{ stop: true, errorCode: 'someReason' }`. The framework warn-logs the reason and emits `socketEventNames.logout, 'error'` to the socket — the client treats this the same as a network error and stays signed in. Use the stop signal for things like "require an MFA confirmation before logging out" or "block logout while a transaction is still in flight".
|
|
55
|
-
|
|
56
|
-
### 2. `deleteSession` (skipped when `skipSessionDelete: true`)
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
if (!skipSessionDelete) {
|
|
60
|
-
await deleteSession(token);
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
`deleteSession` does its own `preSessionDelete` / `postSessionDelete` dispatch (see [`./session-management.md`](./session-management.md)). The `skipSessionDelete` flag exists specifically for the case where `deleteSession` ITSELF called `logout` — if we re-called `deleteSession` from inside, we'd recurse forever (or at least until the second `getRaw` returned null).
|
|
65
|
-
|
|
66
|
-
### 3. Remove the token from the active-users set
|
|
67
|
-
|
|
68
|
-
```ts
|
|
69
|
-
if (userId) {
|
|
70
|
-
await redisClient.srem(activeUsersKeyFor(userId), token);
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
This is the bookkeeping that `revokeUserSessions` / single-session enforcement reads. The set is keyed by user id, with members being currently-valid tokens. Removing the token before the session is gone-from-Redis is fine because `srem` is idempotent and the source of truth for "is this session valid" is the session record, not the set.
|
|
75
|
-
|
|
76
|
-
The set might already not contain the token (e.g. when `deleteSession` already cleaned up via `adapter.untrackActive`). `srem` is idempotent — extra calls are no-ops, not errors.
|
|
77
|
-
|
|
78
|
-
### 4. Leave the socket room
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
socket.leave(token);
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Every authenticated socket joins a Socket.io room named after its session token. Broadcasts to that room are what drive `updateSession` and `sessionReplaced` events. After leaving, the socket no longer receives session-scoped broadcasts.
|
|
85
|
-
|
|
86
|
-
### 5. `postLogout` dispatch
|
|
87
|
-
|
|
88
|
-
```ts
|
|
89
|
-
await dispatchHook('postLogout', { userId, token });
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Same payload as `preLogout`. Subscribers do their teardown here. Critical example: `@luckystack/presence` registers a `postLogout` handler that cleans up `disconnectTimers` and `tempDisconnectedSockets`. This keeps the login → presence dependency one-way:
|
|
93
|
-
|
|
94
|
-
- `@luckystack/presence` knows about `@luckystack/login` (uses `getSession` / `deleteSession`).
|
|
95
|
-
- `@luckystack/login` does NOT know about `@luckystack/presence`. Presence is just one of many hook subscribers.
|
|
96
|
-
|
|
97
|
-
### 6. Socket emit (`"success"` or `"error"`)
|
|
98
|
-
|
|
99
|
-
```ts
|
|
100
|
-
socket.emit(socketEventNames.logout, "success");
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
The installer-side `SessionProvider` (in `src/_providers/SessionProvider.tsx`) listens for `socketEventNames.logout` and on `"success"`:
|
|
104
|
-
|
|
105
|
-
1. Clears `sessionStorage`.
|
|
106
|
-
2. Removes any cached avatar data URLs.
|
|
107
|
-
3. Redirects to `/login`.
|
|
108
|
-
|
|
109
|
-
On `"error"`, it does nothing — the user stays signed in. The client treats an error the same as a transient network failure.
|
|
110
|
-
|
|
111
|
-
## Spurious-logout warn-logging
|
|
112
|
-
|
|
113
|
-
The success path emits a noisy warn-level log with a stacktrace:
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
getLogger().warn('[session] logout success — emitting logout to socket', {
|
|
117
|
-
tokenPrefix: token ? token.slice(0, 8) : null,
|
|
118
|
-
userId,
|
|
119
|
-
socketId: socket.id,
|
|
120
|
-
stack: new Error('logout invoked').stack,
|
|
121
|
-
});
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
This is deliberate — and the comment in the source explains why:
|
|
125
|
-
|
|
126
|
-
> Spurious logouts are always a bug — log loudly so the originating caller is identifiable from the stacktrace. The client receives this success-emit and clears sessionStorage + redirects to /login, so any unexpected occurrence here is the smoking gun.
|
|
127
|
-
|
|
128
|
-
When a production user reports "I keep getting logged out", grepping the logs for this line + correlating the stacktrace tells you whether it was an explicit logout (good), a `revokeUserSessions` call (good but verify), a single-session enforcement kick (good — the user signed in elsewhere), or an unexpected caller (bug). Keep the warn-level — silencing it loses the diagnostic.
|
|
129
|
-
|
|
130
|
-
Tokens are logged at 8-char prefix length so the full token never lands in a log file.
|
|
131
|
-
|
|
132
|
-
## `skipSessionDelete: true` — when it's correct
|
|
133
|
-
|
|
134
|
-
Used by exactly one caller: `deleteSession` itself, when it walks the socket room for the deleted session:
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
// inside deleteSession, when sockets are still connected
|
|
138
|
-
const { logout } = await import('./logout');
|
|
139
|
-
const sockets = ioInstance.sockets.adapter.rooms.get(token);
|
|
140
|
-
if (sockets) {
|
|
141
|
-
await Promise.all([...sockets].map(async (socketId) => {
|
|
142
|
-
const socket = ioInstance.sockets.sockets.get(socketId);
|
|
143
|
-
if (!socket) return;
|
|
144
|
-
await logout({
|
|
145
|
-
token,
|
|
146
|
-
socket,
|
|
147
|
-
userId: resolvedUserId,
|
|
148
|
-
skipSessionDelete: true, // we're INSIDE deleteSession; do not recurse
|
|
149
|
-
});
|
|
150
|
-
}));
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Without the flag, each `logout` call would re-invoke `deleteSession`, which would re-discover the same socket(s), which would re-call `logout`, which would re-invoke `deleteSession` — infinite loop until Redis returned `null` for the second `getRaw`.
|
|
155
|
-
|
|
156
|
-
If you call `logout` directly from anywhere else, leave `skipSessionDelete` undefined / false. The default behavior — `deleteSession` runs inside `logout` — is correct for every other call site.
|
|
157
|
-
|
|
158
|
-
## How force-logout flows reuse the same chain
|
|
159
|
-
|
|
160
|
-
`revokeUserSessions(userId, exceptToken?)` is the "sign out everywhere" / "sign out other devices" entry point:
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
const revokeUserSessions = async (userId, exceptToken?) => {
|
|
164
|
-
const adapter = getSessionAdapter();
|
|
165
|
-
const tokens = await adapter.listActive(userId);
|
|
166
|
-
const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
|
|
167
|
-
await Promise.all(targets.map((token) => deleteSession(token)));
|
|
168
|
-
return targets.length;
|
|
169
|
-
};
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
Each `deleteSession(token)` finds the socket(s) for that token and calls `logout` on them with `skipSessionDelete: true`. The end result for every device is identical to a normal user-initiated logout: `preLogout` fires, `postLogout` fires, sessionStorage is cleared, redirect to `/login`. There is no second code path that "force-logs out" differently — every disconnect goes through the same socket flow.
|
|
173
|
-
|
|
174
|
-
Use cases:
|
|
175
|
-
|
|
176
|
-
- **Password change** — `revokeUserSessions(user.id, currentToken)` so every other device is logged out, but the device that just changed the password stays signed in.
|
|
177
|
-
- **Account deletion** — `revokeUserSessions(user.id)` (no `exceptToken`) before deleting the user record so no socket is holding onto a stale session.
|
|
178
|
-
- **"Sign out everywhere" button** — same as account deletion but without the deletion.
|
|
179
|
-
- **Admin "boot user" tool** — `revokeUserSessions(targetUserId)` from an admin endpoint.
|
|
180
|
-
|
|
181
|
-
## Single-session enforcement
|
|
182
|
-
|
|
183
|
-
When a user signs in elsewhere and `session.perUser === 'single'`, `saveSession` calls `logout` for each previous token's socket(s). The optional `session.notifyOldDeviceOnRevoke` flag emits `socketEventNames.sessionReplaced` BEFORE the disconnect so the kicked client can show a "you signed in elsewhere" toast:
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
if (sessionCfg.notifyOldDeviceOnRevoke) {
|
|
187
|
-
io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
|
|
188
|
-
reason: 'session-replaced',
|
|
189
|
-
userId,
|
|
190
|
-
}));
|
|
191
|
-
}
|
|
192
|
-
// ...then logout fires, which emits socketEventNames.logout, "success"
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Both events land on the same socket. The installer-side `SessionProvider` handles `sessionReplaced` first (show the toast), then `logout` (clear sessionStorage, redirect).
|
|
196
|
-
|
|
197
|
-
## Hook recipes
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
// Audit-log every logout
|
|
201
|
-
registerHook('postLogout', async ({ userId, token }) => {
|
|
202
|
-
if (!userId || !token) return;
|
|
203
|
-
await prisma.auditLog.create({
|
|
204
|
-
data: {
|
|
205
|
-
action: 'logout',
|
|
206
|
-
userId,
|
|
207
|
-
tokenPrefix: token.slice(0, 8),
|
|
208
|
-
timestamp: new Date(),
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// Block logout while a critical transaction is pending
|
|
214
|
-
registerHook('preLogout', async ({ userId }) => {
|
|
215
|
-
if (!userId) return;
|
|
216
|
-
const pending = await prisma.pendingTransaction.count({ where: { userId } });
|
|
217
|
-
if (pending > 0) {
|
|
218
|
-
return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Tear down per-user caches (login does NOT know about this — it's a hook subscriber)
|
|
223
|
-
registerHook('postLogout', async ({ userId }) => {
|
|
224
|
-
if (!userId) return;
|
|
225
|
-
cache.del(`user-prefs:${userId}`);
|
|
226
|
-
cache.del(`user-feed:${userId}`);
|
|
227
|
-
});
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
## Related
|
|
231
|
-
|
|
232
|
-
- [`./session-management.md`](./session-management.md) — `deleteSession`, the session adapter, sliding expiration.
|
|
233
|
-
- [`./hooks.md`](./hooks.md) — `preLogout` / `postLogout` payloads and the stop-signal contract.
|
|
234
|
-
- [`./credentials-auth.md`](./credentials-auth.md) — the corresponding login flow.
|
|
235
|
-
- Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).
|
|
1
|
+
# Logout flow
|
|
2
|
+
|
|
3
|
+
Deep-dive on `logout` and how it interacts with sockets, sessions, hooks, and presence. Canonical source: [`./src/logout.ts`](../src/logout.ts), with cross-cutting flow in [`./src/session.ts`](../src/session.ts).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Entry point
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
logout({
|
|
11
|
+
token: string | null,
|
|
12
|
+
socket: Socket | undefined,
|
|
13
|
+
userId: string | null,
|
|
14
|
+
skipSessionDelete?: boolean,
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
All four arguments are nullable / optional because `logout` is called from three different contexts that each know about different subsets of state:
|
|
19
|
+
|
|
20
|
+
1. **The socket's `logout` event handler** — has the socket and token; resolves `userId` from the session lookup.
|
|
21
|
+
2. **`deleteSession`** — has the token and (after a session read) the userId; needs `skipSessionDelete: true` to avoid recursion.
|
|
22
|
+
3. **Single-session enforcement inside `saveSession`** — has the previous token + socket + userId, calls `logout` to disconnect the old device when the new login arrives.
|
|
23
|
+
|
|
24
|
+
Missing arguments cause the function to warn-log and return early without mutating state. Specifically:
|
|
25
|
+
|
|
26
|
+
- No `socket` → `getLogger().warn('logout: invalid socket')` and return. The session is NOT deleted in this case — there's nothing to emit to, but more importantly, "no socket" is an unexpected state and silently destroying the session would hide the bug.
|
|
27
|
+
- No `token` → `getLogger().warn('logout: no token provided')` and return.
|
|
28
|
+
|
|
29
|
+
If you need to programmatically log out a user without a live socket, call `deleteSession(token)` directly. `logout` is the socket-side wrapper.
|
|
30
|
+
|
|
31
|
+
## Sequence
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
preLogout (can stop) → [deleteSession] → srem activeUsers → socket.leave(token) → postLogout → socket.emit(logout, "success")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Step by step, mirroring the source:
|
|
38
|
+
|
|
39
|
+
### 1. `preLogout` dispatch
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const preLogoutResult = await dispatchHook('preLogout', { userId, token });
|
|
43
|
+
if (preLogoutResult.stopped) {
|
|
44
|
+
getLogger().warn('logout: aborted by preLogout hook', {
|
|
45
|
+
errorCode: preLogoutResult.signal.errorCode,
|
|
46
|
+
});
|
|
47
|
+
socket.emit(socketEventNames.logout, 'error');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Payload: `{ userId: string | null, token: string | null }`. Both are nullable because logout is sometimes invoked on a socket that already lost its session (cleanup path).
|
|
53
|
+
|
|
54
|
+
A handler can return `{ stop: true, errorCode: 'someReason' }`. The framework warn-logs the reason and emits `socketEventNames.logout, 'error'` to the socket — the client treats this the same as a network error and stays signed in. Use the stop signal for things like "require an MFA confirmation before logging out" or "block logout while a transaction is still in flight".
|
|
55
|
+
|
|
56
|
+
### 2. `deleteSession` (skipped when `skipSessionDelete: true`)
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
if (!skipSessionDelete) {
|
|
60
|
+
await deleteSession(token);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`deleteSession` does its own `preSessionDelete` / `postSessionDelete` dispatch (see [`./session-management.md`](./session-management.md)). The `skipSessionDelete` flag exists specifically for the case where `deleteSession` ITSELF called `logout` — if we re-called `deleteSession` from inside, we'd recurse forever (or at least until the second `getRaw` returned null).
|
|
65
|
+
|
|
66
|
+
### 3. Remove the token from the active-users set
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
if (userId) {
|
|
70
|
+
await redisClient.srem(activeUsersKeyFor(userId), token);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This is the bookkeeping that `revokeUserSessions` / single-session enforcement reads. The set is keyed by user id, with members being currently-valid tokens. Removing the token before the session is gone-from-Redis is fine because `srem` is idempotent and the source of truth for "is this session valid" is the session record, not the set.
|
|
75
|
+
|
|
76
|
+
The set might already not contain the token (e.g. when `deleteSession` already cleaned up via `adapter.untrackActive`). `srem` is idempotent — extra calls are no-ops, not errors.
|
|
77
|
+
|
|
78
|
+
### 4. Leave the socket room
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
socket.leave(token);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every authenticated socket joins a Socket.io room named after its session token. Broadcasts to that room are what drive `updateSession` and `sessionReplaced` events. After leaving, the socket no longer receives session-scoped broadcasts.
|
|
85
|
+
|
|
86
|
+
### 5. `postLogout` dispatch
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
await dispatchHook('postLogout', { userId, token });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Same payload as `preLogout`. Subscribers do their teardown here. Critical example: `@luckystack/presence` registers a `postLogout` handler that cleans up `disconnectTimers` and `tempDisconnectedSockets`. This keeps the login → presence dependency one-way:
|
|
93
|
+
|
|
94
|
+
- `@luckystack/presence` knows about `@luckystack/login` (uses `getSession` / `deleteSession`).
|
|
95
|
+
- `@luckystack/login` does NOT know about `@luckystack/presence`. Presence is just one of many hook subscribers.
|
|
96
|
+
|
|
97
|
+
### 6. Socket emit (`"success"` or `"error"`)
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
socket.emit(socketEventNames.logout, "success");
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The installer-side `SessionProvider` (in `src/_providers/SessionProvider.tsx`) listens for `socketEventNames.logout` and on `"success"`:
|
|
104
|
+
|
|
105
|
+
1. Clears `sessionStorage`.
|
|
106
|
+
2. Removes any cached avatar data URLs.
|
|
107
|
+
3. Redirects to `/login`.
|
|
108
|
+
|
|
109
|
+
On `"error"`, it does nothing — the user stays signed in. The client treats an error the same as a transient network failure.
|
|
110
|
+
|
|
111
|
+
## Spurious-logout warn-logging
|
|
112
|
+
|
|
113
|
+
The success path emits a noisy warn-level log with a stacktrace:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
getLogger().warn('[session] logout success — emitting logout to socket', {
|
|
117
|
+
tokenPrefix: token ? token.slice(0, 8) : null,
|
|
118
|
+
userId,
|
|
119
|
+
socketId: socket.id,
|
|
120
|
+
stack: new Error('logout invoked').stack,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This is deliberate — and the comment in the source explains why:
|
|
125
|
+
|
|
126
|
+
> Spurious logouts are always a bug — log loudly so the originating caller is identifiable from the stacktrace. The client receives this success-emit and clears sessionStorage + redirects to /login, so any unexpected occurrence here is the smoking gun.
|
|
127
|
+
|
|
128
|
+
When a production user reports "I keep getting logged out", grepping the logs for this line + correlating the stacktrace tells you whether it was an explicit logout (good), a `revokeUserSessions` call (good but verify), a single-session enforcement kick (good — the user signed in elsewhere), or an unexpected caller (bug). Keep the warn-level — silencing it loses the diagnostic.
|
|
129
|
+
|
|
130
|
+
Tokens are logged at 8-char prefix length so the full token never lands in a log file.
|
|
131
|
+
|
|
132
|
+
## `skipSessionDelete: true` — when it's correct
|
|
133
|
+
|
|
134
|
+
Used by exactly one caller: `deleteSession` itself, when it walks the socket room for the deleted session:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// inside deleteSession, when sockets are still connected
|
|
138
|
+
const { logout } = await import('./logout');
|
|
139
|
+
const sockets = ioInstance.sockets.adapter.rooms.get(token);
|
|
140
|
+
if (sockets) {
|
|
141
|
+
await Promise.all([...sockets].map(async (socketId) => {
|
|
142
|
+
const socket = ioInstance.sockets.sockets.get(socketId);
|
|
143
|
+
if (!socket) return;
|
|
144
|
+
await logout({
|
|
145
|
+
token,
|
|
146
|
+
socket,
|
|
147
|
+
userId: resolvedUserId,
|
|
148
|
+
skipSessionDelete: true, // we're INSIDE deleteSession; do not recurse
|
|
149
|
+
});
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Without the flag, each `logout` call would re-invoke `deleteSession`, which would re-discover the same socket(s), which would re-call `logout`, which would re-invoke `deleteSession` — infinite loop until Redis returned `null` for the second `getRaw`.
|
|
155
|
+
|
|
156
|
+
If you call `logout` directly from anywhere else, leave `skipSessionDelete` undefined / false. The default behavior — `deleteSession` runs inside `logout` — is correct for every other call site.
|
|
157
|
+
|
|
158
|
+
## How force-logout flows reuse the same chain
|
|
159
|
+
|
|
160
|
+
`revokeUserSessions(userId, exceptToken?)` is the "sign out everywhere" / "sign out other devices" entry point:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const revokeUserSessions = async (userId, exceptToken?) => {
|
|
164
|
+
const adapter = getSessionAdapter();
|
|
165
|
+
const tokens = await adapter.listActive(userId);
|
|
166
|
+
const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
|
|
167
|
+
await Promise.all(targets.map((token) => deleteSession(token)));
|
|
168
|
+
return targets.length;
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Each `deleteSession(token)` finds the socket(s) for that token and calls `logout` on them with `skipSessionDelete: true`. The end result for every device is identical to a normal user-initiated logout: `preLogout` fires, `postLogout` fires, sessionStorage is cleared, redirect to `/login`. There is no second code path that "force-logs out" differently — every disconnect goes through the same socket flow.
|
|
173
|
+
|
|
174
|
+
Use cases:
|
|
175
|
+
|
|
176
|
+
- **Password change** — `revokeUserSessions(user.id, currentToken)` so every other device is logged out, but the device that just changed the password stays signed in.
|
|
177
|
+
- **Account deletion** — `revokeUserSessions(user.id)` (no `exceptToken`) before deleting the user record so no socket is holding onto a stale session.
|
|
178
|
+
- **"Sign out everywhere" button** — same as account deletion but without the deletion.
|
|
179
|
+
- **Admin "boot user" tool** — `revokeUserSessions(targetUserId)` from an admin endpoint.
|
|
180
|
+
|
|
181
|
+
## Single-session enforcement
|
|
182
|
+
|
|
183
|
+
When a user signs in elsewhere and `session.perUser === 'single'`, `saveSession` calls `logout` for each previous token's socket(s). The optional `session.notifyOldDeviceOnRevoke` flag emits `socketEventNames.sessionReplaced` BEFORE the disconnect so the kicked client can show a "you signed in elsewhere" toast:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
if (sessionCfg.notifyOldDeviceOnRevoke) {
|
|
187
|
+
io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
|
|
188
|
+
reason: 'session-replaced',
|
|
189
|
+
userId,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
// ...then logout fires, which emits socketEventNames.logout, "success"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Both events land on the same socket. The installer-side `SessionProvider` handles `sessionReplaced` first (show the toast), then `logout` (clear sessionStorage, redirect).
|
|
196
|
+
|
|
197
|
+
## Hook recipes
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// Audit-log every logout
|
|
201
|
+
registerHook('postLogout', async ({ userId, token }) => {
|
|
202
|
+
if (!userId || !token) return;
|
|
203
|
+
await prisma.auditLog.create({
|
|
204
|
+
data: {
|
|
205
|
+
action: 'logout',
|
|
206
|
+
userId,
|
|
207
|
+
tokenPrefix: token.slice(0, 8),
|
|
208
|
+
timestamp: new Date(),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Block logout while a critical transaction is pending
|
|
214
|
+
registerHook('preLogout', async ({ userId }) => {
|
|
215
|
+
if (!userId) return;
|
|
216
|
+
const pending = await prisma.pendingTransaction.count({ where: { userId } });
|
|
217
|
+
if (pending > 0) {
|
|
218
|
+
return { stop: true, errorCode: 'login.pendingTransactionsBlocking' };
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Tear down per-user caches (login does NOT know about this — it's a hook subscriber)
|
|
223
|
+
registerHook('postLogout', async ({ userId }) => {
|
|
224
|
+
if (!userId) return;
|
|
225
|
+
cache.del(`user-prefs:${userId}`);
|
|
226
|
+
cache.del(`user-feed:${userId}`);
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Related
|
|
231
|
+
|
|
232
|
+
- [`./session-management.md`](./session-management.md) — `deleteSession`, the session adapter, sliding expiration.
|
|
233
|
+
- [`./hooks.md`](./hooks.md) — `preLogout` / `postLogout` payloads and the stop-signal contract.
|
|
234
|
+
- [`./credentials-auth.md`](./credentials-auth.md) — the corresponding login flow.
|
|
235
|
+
- Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).
|