@luckystack/login 0.1.8 → 0.2.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/CLAUDE.md +194 -194
- package/README.md +239 -212
- package/dist/chunk-22NFCMM7.js +299 -0
- package/dist/chunk-22NFCMM7.js.map +1 -0
- package/dist/chunk-NXXBZT3I.js +401 -0
- package/dist/chunk-NXXBZT3I.js.map +1 -0
- package/dist/index.d.ts +281 -90
- package/dist/index.js +529 -792
- package/dist/index.js.map +1 -1
- package/dist/logout-QQRCD2AX.js +7 -0
- package/dist/logout-QQRCD2AX.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +69 -0
- package/dist/register.js.map +1 -0
- 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 +9 -4
|
@@ -1,286 +1,286 @@
|
|
|
1
|
-
# Session management
|
|
2
|
-
|
|
3
|
-
Deep-dive on `saveSession` / `getSession` / `deleteSession`, sliding expiration, single-session enforcement, and the swappable `SessionAdapter`. Canonical sources: [`./src/session.ts`](../src/session.ts) and [`./src/sessionAdapter.ts`](../src/sessionAdapter.ts).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Storage layout (defaults — Redis adapter)
|
|
8
|
-
|
|
9
|
-
| Key | Type | Contents |
|
|
10
|
-
| -------------------------------------------------- | ---------- | --------------------------------------------------------------------------------- |
|
|
11
|
-
| `${projectName}-session:<token>` | string | JSON-encoded `BaseSessionLayout` (id, email, name, csrfToken, custom fields). |
|
|
12
|
-
| `${projectName}-activeUsers:<userId>` | set | Tokens currently associated with this user. Read by single-session enforcement. |
|
|
13
|
-
| `${projectName}-oauth-state:<provider>:<state>` | string | `'1'` sentinel + TTL. Consumed atomically on OAuth callback. (See `login.ts`.) |
|
|
14
|
-
| `${projectName}-pwreset:<token>` | string | `userId` bound to the reset token. (See [`./password-reset.md`](./password-reset.md).) |
|
|
15
|
-
|
|
16
|
-
`projectName` comes from `getProjectName()` in `@luckystack/core` — a single source of truth so the namespace is identical across sessions, activeUsers, OAuth state, password reset, and rate-limit.
|
|
17
|
-
|
|
18
|
-
The keys are exported as helpers for downstream admin tooling that reads Redis directly:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
import { sessionKeyFor, activeUsersKeyFor } from '@luckystack/login';
|
|
22
|
-
|
|
23
|
-
// In an admin script:
|
|
24
|
-
const raw = await redis.get(sessionKeyFor(token));
|
|
25
|
-
const tokens = await redis.smembers(activeUsersKeyFor(userId));
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
For new code, prefer `getSessionAdapter()` so the keys stay encapsulated and a non-Redis adapter doesn't break the tooling.
|
|
29
|
-
|
|
30
|
-
## API surface
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
saveSession(token: string, data: SessionLayout, newUser?: boolean): Promise<void>;
|
|
34
|
-
getSession(token: string | null): Promise<SessionLayout | null>;
|
|
35
|
-
deleteSession(token: string): Promise<boolean>;
|
|
36
|
-
getAllSessions(): Promise<SessionLayout[]>;
|
|
37
|
-
revokeUserSessions(userId: string, exceptToken?: string | null): Promise<number>;
|
|
38
|
-
sessionKeyFor(token: string): string;
|
|
39
|
-
activeUsersKeyFor(userId: string): string;
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
`SessionLayout` is the framework's `BaseSessionLayout` from `@luckystack/core`, augmentable via module declaration — see [`./oauth-providers.md`](./oauth-providers.md) for the `extraSessionFields` pattern.
|
|
43
|
-
|
|
44
|
-
## `saveSession` — write path
|
|
45
|
-
|
|
46
|
-
The full sequence on a `newUser: true` save:
|
|
47
|
-
|
|
48
|
-
1. **`preSessionCreate` dispatch** — `{ token, user, persistent: !session.basedToken }`. A handler can return a stop signal; the warn-log surfaces the `errorCode` and the function returns without writing.
|
|
49
|
-
2. **CSRF token mint** — if `data.csrfToken` is not present, mint a fresh 32-byte hex token and write it onto the data. Subsequent writes preserve it so the client doesn't need to re-fetch on every session update. Rotated on logout (via `deleteSession`).
|
|
50
|
-
3. **Adapter write** — `adapter.setRaw(token, JSON.stringify(data), ttl)`. TTL = `session.expiryDays * 86400`.
|
|
51
|
-
4. **`trackActive`** — `adapter.trackActive(userId, token, ttl)` so the active-tokens set is in sync.
|
|
52
|
-
5. **Single-session enforcement** — see the next section.
|
|
53
|
-
6. **Broadcast** — if a socket is in the token room (`io.sockets.adapter.rooms.has(token)`), emit `socketEventNames.updateSession` with the new JSON. Drives client-side session refresh without a round-trip.
|
|
54
|
-
7. **`postSessionCreate` dispatch** — same payload as the pre-version.
|
|
55
|
-
|
|
56
|
-
On a `newUser: false` save (used for in-place session edits — admin promoting a user, settings update, etc.), steps 1 / 5 / 7 are skipped. Steps 2, 3, 4, 6 still run.
|
|
57
|
-
|
|
58
|
-
`saveSession` is wrapped in `tryCatch` end-to-end; errors are logged but never thrown out of the function. A failed save does not block the request that triggered it — but does mean the session is stale, so the next `getSession` will re-read from Redis if it can.
|
|
59
|
-
|
|
60
|
-
## Sliding expiration — `getSession`
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
preSessionRefresh (can stop the TTL extend) → adapter.expire(token, newTtl) → postSessionRefresh
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Each authenticated `getSession` call extends the TTL by `session.expiryDays`. That means an active user is never logged out by the framework — only an idle one whose sliding window expires.
|
|
67
|
-
|
|
68
|
-
The hook gate exists for use cases like:
|
|
69
|
-
|
|
70
|
-
- **Admin freezing a session** — `preSessionRefresh` returns a stop signal while the admin reviews the account; the session keeps its current TTL but is not extended further, so it expires naturally even while the user keeps refreshing the page.
|
|
71
|
-
- **Sliding-window analytics** — `postSessionRefresh` receives `{ token, userId, oldTtl, newTtl, applied }` and is the canonical place to track "user is active right now" without re-reading the session.
|
|
72
|
-
|
|
73
|
-
`applied` reflects whether `adapter.expire` actually extended the TTL (true) or was skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend). Audit handlers should branch on `applied` to distinguish "user is active" from "user has a hold on their session".
|
|
74
|
-
|
|
75
|
-
Return value: the parsed `SessionLayout` with the `token` re-injected, or `null` when the token is missing / record is gone / the JSON failed to parse.
|
|
76
|
-
|
|
77
|
-
## `deleteSession` — write path with visible stacktrace
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
getLogger().warn('[session] deleteSession invoked', {
|
|
81
|
-
tokenPrefix: token.slice(0, 8),
|
|
82
|
-
stack: new Error('deleteSession invoked').stack,
|
|
83
|
-
});
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The function intentionally warn-logs with a stacktrace BEFORE doing anything — same reasoning as the `logout` warn-log: spurious deletes silently kick users, and the stacktrace is the smoking gun.
|
|
87
|
-
|
|
88
|
-
After the warn-log:
|
|
89
|
-
|
|
90
|
-
1. **Read raw** — `adapter.getRaw(token)` to extract `userId` from the JSON. Used as the hook payload's `userId` and to clean up the active-users set.
|
|
91
|
-
2. **`preSessionDelete` dispatch** — `{ token, userId: resolvedUserId | null }`. Stop signal warn-logs and returns `false`.
|
|
92
|
-
3. **Logout connected sockets** — if `getIoInstance()` is up and there are sockets in the token room, call `logout(...)` for each one with `skipSessionDelete: true` (we're already inside the delete; don't recurse). See [`./logout-flow.md`](./logout-flow.md).
|
|
93
|
-
4. **`adapter.untrackActive`** — removes the token from the active-users set.
|
|
94
|
-
5. **`adapter.delete`** — removes the session record itself.
|
|
95
|
-
6. **`postSessionDelete` dispatch** — same payload as pre.
|
|
96
|
-
7. **Return** `true` on success, `false` when stopped or when an error bubbled.
|
|
97
|
-
|
|
98
|
-
When `getRaw` returned null (the record was already gone), the function still dispatches the pre/post hooks (with `userId: null`) and calls `adapter.delete` for idempotency, but skips the socket-walk and `untrackActive` because there's nothing to clean up.
|
|
99
|
-
|
|
100
|
-
## `getAllSessions` — graceful degradation
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
if (!adapter.listAll) {
|
|
104
|
-
getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
Some adapters can't enumerate by design — signed-JWT-stateless adapters have no storage to walk; log-only / audit-only adapters might shed reads. Rather than throw, the function warn-logs once and returns `[]`. Admin code should treat the empty result as "this adapter is not scannable" rather than "this user has no sessions".
|
|
110
|
-
|
|
111
|
-
The Redis adapter implements `listAll` via `SCAN` over `${projectName}-session:*`, collecting `{ token, raw }` pairs. The walk paginates with `COUNT 100` so it doesn't block the event loop on large stores.
|
|
112
|
-
|
|
113
|
-
## `revokeUserSessions(userId, exceptToken?)`
|
|
114
|
-
|
|
115
|
-
Pure orchestration on top of `deleteSession`:
|
|
116
|
-
|
|
117
|
-
```ts
|
|
118
|
-
const tokens = await adapter.listActive(userId);
|
|
119
|
-
const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
|
|
120
|
-
await Promise.all(targets.map((token) => deleteSession(token)));
|
|
121
|
-
return targets.length;
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
`exceptToken` is the "keep me signed in" escape hatch:
|
|
125
|
-
|
|
126
|
-
- After a password change: call with the current request's token so the user stays signed in on the device they just changed it on.
|
|
127
|
-
- After account deletion / "sign out everywhere": omit `exceptToken` to revoke every session including the caller's.
|
|
128
|
-
|
|
129
|
-
The return value is the number of sessions revoked, which lets the caller surface "Signed out of N other devices" in the UI.
|
|
130
|
-
|
|
131
|
-
## Single-session enforcement
|
|
132
|
-
|
|
133
|
-
When a `newUser: true` `saveSession` runs, the function decides which (if any) previous sessions to kick. The decision tree:
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
let effectivePerUser = sessionCfg.allowMultiple ? 'multiple' : sessionCfg.perUser;
|
|
137
|
-
const cap = sessionCfg.maxConcurrentPerUser;
|
|
138
|
-
const previousTokens = (await adapter.listActive(userId)).filter(t => t !== token);
|
|
139
|
-
|
|
140
|
-
if (effectivePerUser === 'single') {
|
|
141
|
-
tokensToKick = previousTokens; // kick all
|
|
142
|
-
} else if (
|
|
143
|
-
effectivePerUser === 'multiple' &&
|
|
144
|
-
cap !== null &&
|
|
145
|
-
previousTokens.length + 1 > cap &&
|
|
146
|
-
sessionCfg.onConflict === 'revokeOld'
|
|
147
|
-
) {
|
|
148
|
-
const excess = previousTokens.length + 1 - cap;
|
|
149
|
-
tokensToKick = previousTokens.slice(0, excess); // kick oldest until under cap
|
|
150
|
-
}
|
|
151
|
-
// else (multiple, no cap; or multiple + 'rejectNew') — nothing to kick here
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Reading the config:
|
|
155
|
-
|
|
156
|
-
| Slot | Possible values | Behavior |
|
|
157
|
-
| ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
158
|
-
| `session.perUser` | `'single'` / `'multiple'` | `'single'` (default) — only one session at a time. `'multiple'` — pair with `maxConcurrentPerUser`. |
|
|
159
|
-
| `session.allowMultiple` (legacy) | `boolean` | When `false`, collapses to `'single'` regardless of `perUser`. Backward-compat shim. |
|
|
160
|
-
| `session.maxConcurrentPerUser` | `number \| null` | Cap on simultaneous sessions when `perUser === 'multiple'`. `null` = unlimited. |
|
|
161
|
-
| `session.onConflict` | `'revokeOld'` / `'rejectNew'` | What to do when the cap is reached. `'revokeOld'` kicks oldest; `'rejectNew'` refuses the new login. |
|
|
162
|
-
| `session.notifyOldDeviceOnRevoke` | `boolean` | Emit `socketEventNames.sessionReplaced` to the kicked socket before the disconnect. |
|
|
163
|
-
| `session.basedToken` | `boolean` | Used to compute `persistent: !basedToken` for `preSessionCreate` / `postSessionCreate`. |
|
|
164
|
-
| `session.expiryDays` | `number` | Multiplied by 86400 to get the TTL in seconds. |
|
|
165
|
-
|
|
166
|
-
`onConflict === 'rejectNew'` is enforced at the API layer (the login endpoint refuses the request), not inside `saveSession` — by the time we reach `saveSession` the decision to allow the login has already been made.
|
|
167
|
-
|
|
168
|
-
For each token in `tokensToKick`:
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
const sockets = io.sockets.adapter.rooms.get(previousToken);
|
|
172
|
-
if (sockets) {
|
|
173
|
-
if (sessionCfg.notifyOldDeviceOnRevoke) {
|
|
174
|
-
io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
|
|
175
|
-
reason: 'session-replaced',
|
|
176
|
-
userId,
|
|
177
|
-
}));
|
|
178
|
-
}
|
|
179
|
-
for (const socketId of sockets) {
|
|
180
|
-
const socket = io.sockets.sockets.get(socketId);
|
|
181
|
-
if (socket) {
|
|
182
|
-
await logout({ token: previousToken, socket, userId });
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
// No active sockets — just clean up the adapter state.
|
|
187
|
-
await adapter.delete(previousToken);
|
|
188
|
-
await adapter.untrackActive(userId, previousToken);
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
The two branches matter: with sockets, the disconnect-flow runs end-to-end. Without sockets (the user was offline), only the storage cleanup runs — there's no socket to emit to and no presence state to tear down.
|
|
193
|
-
|
|
194
|
-
### Ordering caveat
|
|
195
|
-
|
|
196
|
-
The single-session-enforcement logic kicks "the oldest" tokens by dropping the head of `previousTokens.slice(0, excess)`. There is no LRU metadata in the adapter contract — adapters return insertion-order for set-like structures, which is good enough for the common case but not a strict guarantee.
|
|
197
|
-
|
|
198
|
-
If you need precise LRU semantics (e.g. a "kick the device the user hasn't used the most recently" UX), implement a custom `SessionAdapter` that tracks per-token last-touch timestamps in a sorted structure (Redis `ZSET`, Postgres `last_seen DESC`, DynamoDB GSI on `lastSeenAt`).
|
|
199
|
-
|
|
200
|
-
## `SessionAdapter` contract
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
interface SessionAdapter {
|
|
204
|
-
name: string;
|
|
205
|
-
getRaw(token: string): Promise<string | null>;
|
|
206
|
-
setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;
|
|
207
|
-
delete(token: string): Promise<void>;
|
|
208
|
-
expire(token: string, ttlSeconds: number): Promise<boolean>;
|
|
209
|
-
ttl(token: string): Promise<number | null>;
|
|
210
|
-
trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;
|
|
211
|
-
untrackActive(userId: string, token: string): Promise<void>;
|
|
212
|
-
listActive(userId: string): Promise<string[]>;
|
|
213
|
-
listAll?(): Promise<{ token: string; raw: string }[]>;
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
Semantics:
|
|
218
|
-
|
|
219
|
-
| Method | Required behavior |
|
|
220
|
-
| --------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
221
|
-
| `getRaw` | Return the stored string for the token, or `null` when missing. |
|
|
222
|
-
| `setRaw` | Persist the value with the given TTL. Implementations MUST honour the TTL — sessions without one would never expire. |
|
|
223
|
-
| `delete` | Idempotent removal. No-op when the record is already gone. |
|
|
224
|
-
| `expire` | Refresh the TTL only if the record exists. Return `true` when refreshed, `false` otherwise. MUST NOT create on miss. |
|
|
225
|
-
| `ttl` | Remaining TTL in seconds, or `null` when the record is missing. |
|
|
226
|
-
| `trackActive` | Add `token` to the per-user set. Set itself MUST have the same TTL (the longest active session keeps the set alive). |
|
|
227
|
-
| `untrackActive` | Remove `token` from the per-user set. Idempotent. |
|
|
228
|
-
| `listActive` | Return the current per-user set. Empty array (never throw) when missing. |
|
|
229
|
-
| `listAll` | Optional admin walk. Omit when the backend cannot enumerate. |
|
|
230
|
-
|
|
231
|
-
`name` is a human-readable identifier surfaced in logs. The default Redis adapter is `'redis'`.
|
|
232
|
-
|
|
233
|
-
## Writing a custom adapter
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
import { registerSessionAdapter, type SessionAdapter } from '@luckystack/login';
|
|
237
|
-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
238
|
-
|
|
239
|
-
const dynamoAdapter: SessionAdapter = {
|
|
240
|
-
name: 'dynamodb',
|
|
241
|
-
async getRaw(token) { /* GetItem */ },
|
|
242
|
-
async setRaw(token, value, ttlSeconds) { /* PutItem with TTL attribute */ },
|
|
243
|
-
async delete(token) { /* DeleteItem */ },
|
|
244
|
-
async expire(token, ttlSeconds) { /* UpdateItem with condition exists */ },
|
|
245
|
-
async ttl(token) { /* GetItem read TTL attribute */ },
|
|
246
|
-
async trackActive(userId, token, ttlSeconds) { /* UpdateItem ADD to a SS attribute */ },
|
|
247
|
-
async untrackActive(userId, token) { /* UpdateItem DELETE from SS */ },
|
|
248
|
-
async listActive(userId) { /* GetItem read SS */ },
|
|
249
|
-
// listAll omitted — DynamoDB Scan is too expensive in production
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
registerSessionAdapter(dynamoAdapter);
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Common adapter shapes consumers have shipped:
|
|
256
|
-
|
|
257
|
-
- **DynamoDB / Cosmos DB** — serverless / edge deployments where Redis is operationally inconvenient.
|
|
258
|
-
- **Postgres `sessions` table** — when you already operate Postgres and don't want a second datastore. Per-token row with `userId`, `data` (JSON column), `expiresAt`.
|
|
259
|
-
- **Signed-JWT-stateless** — `getRaw` decodes the JWT, `setRaw` is a no-op (the JWT IS the storage), `trackActive` writes to a small Redis hash so single-session enforcement still works. `listAll` is omitted.
|
|
260
|
-
- **In-memory mock** — for integration tests. A `Map<string, { value, expiresAt }>` with a setInterval-based expiration sweep.
|
|
261
|
-
|
|
262
|
-
Call `registerSessionAdapter(...)` once at boot from `luckystack/server/index.ts`, BEFORE the first login request. Last-write-wins on subsequent calls (you can swap adapters in tests this way).
|
|
263
|
-
|
|
264
|
-
## `getSessionAdapter`
|
|
265
|
-
|
|
266
|
-
Reads the currently-active adapter. Defaults to `redisSessionAdapter` when no `registerSessionAdapter` call has happened. The default adapter is exported as `redisSessionAdapter` for cases where consumer code wants to compose around it (e.g. a wrapper adapter that delegates to Redis but also writes to an audit log).
|
|
267
|
-
|
|
268
|
-
## Why a separate broadcast on `updateSession`
|
|
269
|
-
|
|
270
|
-
When `saveSession` writes a `newUser: false` update — for instance an admin granting a user the `admin` flag from a settings page — the broadcast pushes the new session JSON to every socket already in the token room. The client-side `SessionProvider` listens for `socketEventNames.updateSession` and merges the payload into its in-memory session state.
|
|
271
|
-
|
|
272
|
-
This means:
|
|
273
|
-
|
|
274
|
-
- Permission changes apply instantly across all the user's open tabs.
|
|
275
|
-
- Profile updates (name, avatar) reflect in the navbar without a refresh.
|
|
276
|
-
- Custom session fields (e.g. `googleCalendarToken` set via `extraSessionFields`) flow to the client the same way.
|
|
277
|
-
|
|
278
|
-
The broadcast is gated on `io.sockets.adapter.rooms.has(token)` so a save targeting an offline user doesn't waste an emit.
|
|
279
|
-
|
|
280
|
-
## Related
|
|
281
|
-
|
|
282
|
-
- [`./logout-flow.md`](./logout-flow.md) — `logout` and `deleteSession`'s socket-walk.
|
|
283
|
-
- [`./hooks.md`](./hooks.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
|
|
284
|
-
- [`./credentials-auth.md`](./credentials-auth.md) — what calls `saveSession(token, user, true)`.
|
|
285
|
-
- [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and module-augmentation of `BaseSessionLayout`.
|
|
286
|
-
- Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).
|
|
1
|
+
# Session management
|
|
2
|
+
|
|
3
|
+
Deep-dive on `saveSession` / `getSession` / `deleteSession`, sliding expiration, single-session enforcement, and the swappable `SessionAdapter`. Canonical sources: [`./src/session.ts`](../src/session.ts) and [`./src/sessionAdapter.ts`](../src/sessionAdapter.ts).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Storage layout (defaults — Redis adapter)
|
|
8
|
+
|
|
9
|
+
| Key | Type | Contents |
|
|
10
|
+
| -------------------------------------------------- | ---------- | --------------------------------------------------------------------------------- |
|
|
11
|
+
| `${projectName}-session:<token>` | string | JSON-encoded `BaseSessionLayout` (id, email, name, csrfToken, custom fields). |
|
|
12
|
+
| `${projectName}-activeUsers:<userId>` | set | Tokens currently associated with this user. Read by single-session enforcement. |
|
|
13
|
+
| `${projectName}-oauth-state:<provider>:<state>` | string | `'1'` sentinel + TTL. Consumed atomically on OAuth callback. (See `login.ts`.) |
|
|
14
|
+
| `${projectName}-pwreset:<token>` | string | `userId` bound to the reset token. (See [`./password-reset.md`](./password-reset.md).) |
|
|
15
|
+
|
|
16
|
+
`projectName` comes from `getProjectName()` in `@luckystack/core` — a single source of truth so the namespace is identical across sessions, activeUsers, OAuth state, password reset, and rate-limit.
|
|
17
|
+
|
|
18
|
+
The keys are exported as helpers for downstream admin tooling that reads Redis directly:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { sessionKeyFor, activeUsersKeyFor } from '@luckystack/login';
|
|
22
|
+
|
|
23
|
+
// In an admin script:
|
|
24
|
+
const raw = await redis.get(sessionKeyFor(token));
|
|
25
|
+
const tokens = await redis.smembers(activeUsersKeyFor(userId));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For new code, prefer `getSessionAdapter()` so the keys stay encapsulated and a non-Redis adapter doesn't break the tooling.
|
|
29
|
+
|
|
30
|
+
## API surface
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
saveSession(token: string, data: SessionLayout, newUser?: boolean): Promise<void>;
|
|
34
|
+
getSession(token: string | null): Promise<SessionLayout | null>;
|
|
35
|
+
deleteSession(token: string): Promise<boolean>;
|
|
36
|
+
getAllSessions(): Promise<SessionLayout[]>;
|
|
37
|
+
revokeUserSessions(userId: string, exceptToken?: string | null): Promise<number>;
|
|
38
|
+
sessionKeyFor(token: string): string;
|
|
39
|
+
activeUsersKeyFor(userId: string): string;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`SessionLayout` is the framework's `BaseSessionLayout` from `@luckystack/core`, augmentable via module declaration — see [`./oauth-providers.md`](./oauth-providers.md) for the `extraSessionFields` pattern.
|
|
43
|
+
|
|
44
|
+
## `saveSession` — write path
|
|
45
|
+
|
|
46
|
+
The full sequence on a `newUser: true` save:
|
|
47
|
+
|
|
48
|
+
1. **`preSessionCreate` dispatch** — `{ token, user, persistent: !session.basedToken }`. A handler can return a stop signal; the warn-log surfaces the `errorCode` and the function returns without writing.
|
|
49
|
+
2. **CSRF token mint** — if `data.csrfToken` is not present, mint a fresh 32-byte hex token and write it onto the data. Subsequent writes preserve it so the client doesn't need to re-fetch on every session update. Rotated on logout (via `deleteSession`).
|
|
50
|
+
3. **Adapter write** — `adapter.setRaw(token, JSON.stringify(data), ttl)`. TTL = `session.expiryDays * 86400`.
|
|
51
|
+
4. **`trackActive`** — `adapter.trackActive(userId, token, ttl)` so the active-tokens set is in sync.
|
|
52
|
+
5. **Single-session enforcement** — see the next section.
|
|
53
|
+
6. **Broadcast** — if a socket is in the token room (`io.sockets.adapter.rooms.has(token)`), emit `socketEventNames.updateSession` with the new JSON. Drives client-side session refresh without a round-trip.
|
|
54
|
+
7. **`postSessionCreate` dispatch** — same payload as the pre-version.
|
|
55
|
+
|
|
56
|
+
On a `newUser: false` save (used for in-place session edits — admin promoting a user, settings update, etc.), steps 1 / 5 / 7 are skipped. Steps 2, 3, 4, 6 still run.
|
|
57
|
+
|
|
58
|
+
`saveSession` is wrapped in `tryCatch` end-to-end; errors are logged but never thrown out of the function. A failed save does not block the request that triggered it — but does mean the session is stale, so the next `getSession` will re-read from Redis if it can.
|
|
59
|
+
|
|
60
|
+
## Sliding expiration — `getSession`
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
preSessionRefresh (can stop the TTL extend) → adapter.expire(token, newTtl) → postSessionRefresh
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Each authenticated `getSession` call extends the TTL by `session.expiryDays`. That means an active user is never logged out by the framework — only an idle one whose sliding window expires.
|
|
67
|
+
|
|
68
|
+
The hook gate exists for use cases like:
|
|
69
|
+
|
|
70
|
+
- **Admin freezing a session** — `preSessionRefresh` returns a stop signal while the admin reviews the account; the session keeps its current TTL but is not extended further, so it expires naturally even while the user keeps refreshing the page.
|
|
71
|
+
- **Sliding-window analytics** — `postSessionRefresh` receives `{ token, userId, oldTtl, newTtl, applied }` and is the canonical place to track "user is active right now" without re-reading the session.
|
|
72
|
+
|
|
73
|
+
`applied` reflects whether `adapter.expire` actually extended the TTL (true) or was skipped (false — either because the record was already gone, or because `preSessionRefresh` stopped the extend). Audit handlers should branch on `applied` to distinguish "user is active" from "user has a hold on their session".
|
|
74
|
+
|
|
75
|
+
Return value: the parsed `SessionLayout` with the `token` re-injected, or `null` when the token is missing / record is gone / the JSON failed to parse.
|
|
76
|
+
|
|
77
|
+
## `deleteSession` — write path with visible stacktrace
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
getLogger().warn('[session] deleteSession invoked', {
|
|
81
|
+
tokenPrefix: token.slice(0, 8),
|
|
82
|
+
stack: new Error('deleteSession invoked').stack,
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The function intentionally warn-logs with a stacktrace BEFORE doing anything — same reasoning as the `logout` warn-log: spurious deletes silently kick users, and the stacktrace is the smoking gun.
|
|
87
|
+
|
|
88
|
+
After the warn-log:
|
|
89
|
+
|
|
90
|
+
1. **Read raw** — `adapter.getRaw(token)` to extract `userId` from the JSON. Used as the hook payload's `userId` and to clean up the active-users set.
|
|
91
|
+
2. **`preSessionDelete` dispatch** — `{ token, userId: resolvedUserId | null }`. Stop signal warn-logs and returns `false`.
|
|
92
|
+
3. **Logout connected sockets** — if `getIoInstance()` is up and there are sockets in the token room, call `logout(...)` for each one with `skipSessionDelete: true` (we're already inside the delete; don't recurse). See [`./logout-flow.md`](./logout-flow.md).
|
|
93
|
+
4. **`adapter.untrackActive`** — removes the token from the active-users set.
|
|
94
|
+
5. **`adapter.delete`** — removes the session record itself.
|
|
95
|
+
6. **`postSessionDelete` dispatch** — same payload as pre.
|
|
96
|
+
7. **Return** `true` on success, `false` when stopped or when an error bubbled.
|
|
97
|
+
|
|
98
|
+
When `getRaw` returned null (the record was already gone), the function still dispatches the pre/post hooks (with `userId: null`) and calls `adapter.delete` for idempotency, but skips the socket-walk and `untrackActive` because there's nothing to clean up.
|
|
99
|
+
|
|
100
|
+
## `getAllSessions` — graceful degradation
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
if (!adapter.listAll) {
|
|
104
|
+
getLogger().warn(`[session] getAllSessions: adapter '${adapter.name}' does not implement listAll — returning empty`);
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Some adapters can't enumerate by design — signed-JWT-stateless adapters have no storage to walk; log-only / audit-only adapters might shed reads. Rather than throw, the function warn-logs once and returns `[]`. Admin code should treat the empty result as "this adapter is not scannable" rather than "this user has no sessions".
|
|
110
|
+
|
|
111
|
+
The Redis adapter implements `listAll` via `SCAN` over `${projectName}-session:*`, collecting `{ token, raw }` pairs. The walk paginates with `COUNT 100` so it doesn't block the event loop on large stores.
|
|
112
|
+
|
|
113
|
+
## `revokeUserSessions(userId, exceptToken?)`
|
|
114
|
+
|
|
115
|
+
Pure orchestration on top of `deleteSession`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const tokens = await adapter.listActive(userId);
|
|
119
|
+
const targets = exceptToken ? tokens.filter((t) => t !== exceptToken) : tokens;
|
|
120
|
+
await Promise.all(targets.map((token) => deleteSession(token)));
|
|
121
|
+
return targets.length;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`exceptToken` is the "keep me signed in" escape hatch:
|
|
125
|
+
|
|
126
|
+
- After a password change: call with the current request's token so the user stays signed in on the device they just changed it on.
|
|
127
|
+
- After account deletion / "sign out everywhere": omit `exceptToken` to revoke every session including the caller's.
|
|
128
|
+
|
|
129
|
+
The return value is the number of sessions revoked, which lets the caller surface "Signed out of N other devices" in the UI.
|
|
130
|
+
|
|
131
|
+
## Single-session enforcement
|
|
132
|
+
|
|
133
|
+
When a `newUser: true` `saveSession` runs, the function decides which (if any) previous sessions to kick. The decision tree:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
let effectivePerUser = sessionCfg.allowMultiple ? 'multiple' : sessionCfg.perUser;
|
|
137
|
+
const cap = sessionCfg.maxConcurrentPerUser;
|
|
138
|
+
const previousTokens = (await adapter.listActive(userId)).filter(t => t !== token);
|
|
139
|
+
|
|
140
|
+
if (effectivePerUser === 'single') {
|
|
141
|
+
tokensToKick = previousTokens; // kick all
|
|
142
|
+
} else if (
|
|
143
|
+
effectivePerUser === 'multiple' &&
|
|
144
|
+
cap !== null &&
|
|
145
|
+
previousTokens.length + 1 > cap &&
|
|
146
|
+
sessionCfg.onConflict === 'revokeOld'
|
|
147
|
+
) {
|
|
148
|
+
const excess = previousTokens.length + 1 - cap;
|
|
149
|
+
tokensToKick = previousTokens.slice(0, excess); // kick oldest until under cap
|
|
150
|
+
}
|
|
151
|
+
// else (multiple, no cap; or multiple + 'rejectNew') — nothing to kick here
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Reading the config:
|
|
155
|
+
|
|
156
|
+
| Slot | Possible values | Behavior |
|
|
157
|
+
| ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `session.perUser` | `'single'` / `'multiple'` | `'single'` (default) — only one session at a time. `'multiple'` — pair with `maxConcurrentPerUser`. |
|
|
159
|
+
| `session.allowMultiple` (legacy) | `boolean` | When `false`, collapses to `'single'` regardless of `perUser`. Backward-compat shim. |
|
|
160
|
+
| `session.maxConcurrentPerUser` | `number \| null` | Cap on simultaneous sessions when `perUser === 'multiple'`. `null` = unlimited. |
|
|
161
|
+
| `session.onConflict` | `'revokeOld'` / `'rejectNew'` | What to do when the cap is reached. `'revokeOld'` kicks oldest; `'rejectNew'` refuses the new login. |
|
|
162
|
+
| `session.notifyOldDeviceOnRevoke` | `boolean` | Emit `socketEventNames.sessionReplaced` to the kicked socket before the disconnect. |
|
|
163
|
+
| `session.basedToken` | `boolean` | Used to compute `persistent: !basedToken` for `preSessionCreate` / `postSessionCreate`. |
|
|
164
|
+
| `session.expiryDays` | `number` | Multiplied by 86400 to get the TTL in seconds. |
|
|
165
|
+
|
|
166
|
+
`onConflict === 'rejectNew'` is enforced at the API layer (the login endpoint refuses the request), not inside `saveSession` — by the time we reach `saveSession` the decision to allow the login has already been made.
|
|
167
|
+
|
|
168
|
+
For each token in `tokensToKick`:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const sockets = io.sockets.adapter.rooms.get(previousToken);
|
|
172
|
+
if (sockets) {
|
|
173
|
+
if (sessionCfg.notifyOldDeviceOnRevoke) {
|
|
174
|
+
io.to(previousToken).emit(socketEventNames.sessionReplaced, JSON.stringify({
|
|
175
|
+
reason: 'session-replaced',
|
|
176
|
+
userId,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
for (const socketId of sockets) {
|
|
180
|
+
const socket = io.sockets.sockets.get(socketId);
|
|
181
|
+
if (socket) {
|
|
182
|
+
await logout({ token: previousToken, socket, userId });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// No active sockets — just clean up the adapter state.
|
|
187
|
+
await adapter.delete(previousToken);
|
|
188
|
+
await adapter.untrackActive(userId, previousToken);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The two branches matter: with sockets, the disconnect-flow runs end-to-end. Without sockets (the user was offline), only the storage cleanup runs — there's no socket to emit to and no presence state to tear down.
|
|
193
|
+
|
|
194
|
+
### Ordering caveat
|
|
195
|
+
|
|
196
|
+
The single-session-enforcement logic kicks "the oldest" tokens by dropping the head of `previousTokens.slice(0, excess)`. There is no LRU metadata in the adapter contract — adapters return insertion-order for set-like structures, which is good enough for the common case but not a strict guarantee.
|
|
197
|
+
|
|
198
|
+
If you need precise LRU semantics (e.g. a "kick the device the user hasn't used the most recently" UX), implement a custom `SessionAdapter` that tracks per-token last-touch timestamps in a sorted structure (Redis `ZSET`, Postgres `last_seen DESC`, DynamoDB GSI on `lastSeenAt`).
|
|
199
|
+
|
|
200
|
+
## `SessionAdapter` contract
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
interface SessionAdapter {
|
|
204
|
+
name: string;
|
|
205
|
+
getRaw(token: string): Promise<string | null>;
|
|
206
|
+
setRaw(token: string, value: string, ttlSeconds: number): Promise<void>;
|
|
207
|
+
delete(token: string): Promise<void>;
|
|
208
|
+
expire(token: string, ttlSeconds: number): Promise<boolean>;
|
|
209
|
+
ttl(token: string): Promise<number | null>;
|
|
210
|
+
trackActive(userId: string, token: string, ttlSeconds: number): Promise<void>;
|
|
211
|
+
untrackActive(userId: string, token: string): Promise<void>;
|
|
212
|
+
listActive(userId: string): Promise<string[]>;
|
|
213
|
+
listAll?(): Promise<{ token: string; raw: string }[]>;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Semantics:
|
|
218
|
+
|
|
219
|
+
| Method | Required behavior |
|
|
220
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
221
|
+
| `getRaw` | Return the stored string for the token, or `null` when missing. |
|
|
222
|
+
| `setRaw` | Persist the value with the given TTL. Implementations MUST honour the TTL — sessions without one would never expire. |
|
|
223
|
+
| `delete` | Idempotent removal. No-op when the record is already gone. |
|
|
224
|
+
| `expire` | Refresh the TTL only if the record exists. Return `true` when refreshed, `false` otherwise. MUST NOT create on miss. |
|
|
225
|
+
| `ttl` | Remaining TTL in seconds, or `null` when the record is missing. |
|
|
226
|
+
| `trackActive` | Add `token` to the per-user set. Set itself MUST have the same TTL (the longest active session keeps the set alive). |
|
|
227
|
+
| `untrackActive` | Remove `token` from the per-user set. Idempotent. |
|
|
228
|
+
| `listActive` | Return the current per-user set. Empty array (never throw) when missing. |
|
|
229
|
+
| `listAll` | Optional admin walk. Omit when the backend cannot enumerate. |
|
|
230
|
+
|
|
231
|
+
`name` is a human-readable identifier surfaced in logs. The default Redis adapter is `'redis'`.
|
|
232
|
+
|
|
233
|
+
## Writing a custom adapter
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { registerSessionAdapter, type SessionAdapter } from '@luckystack/login';
|
|
237
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
238
|
+
|
|
239
|
+
const dynamoAdapter: SessionAdapter = {
|
|
240
|
+
name: 'dynamodb',
|
|
241
|
+
async getRaw(token) { /* GetItem */ },
|
|
242
|
+
async setRaw(token, value, ttlSeconds) { /* PutItem with TTL attribute */ },
|
|
243
|
+
async delete(token) { /* DeleteItem */ },
|
|
244
|
+
async expire(token, ttlSeconds) { /* UpdateItem with condition exists */ },
|
|
245
|
+
async ttl(token) { /* GetItem read TTL attribute */ },
|
|
246
|
+
async trackActive(userId, token, ttlSeconds) { /* UpdateItem ADD to a SS attribute */ },
|
|
247
|
+
async untrackActive(userId, token) { /* UpdateItem DELETE from SS */ },
|
|
248
|
+
async listActive(userId) { /* GetItem read SS */ },
|
|
249
|
+
// listAll omitted — DynamoDB Scan is too expensive in production
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
registerSessionAdapter(dynamoAdapter);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Common adapter shapes consumers have shipped:
|
|
256
|
+
|
|
257
|
+
- **DynamoDB / Cosmos DB** — serverless / edge deployments where Redis is operationally inconvenient.
|
|
258
|
+
- **Postgres `sessions` table** — when you already operate Postgres and don't want a second datastore. Per-token row with `userId`, `data` (JSON column), `expiresAt`.
|
|
259
|
+
- **Signed-JWT-stateless** — `getRaw` decodes the JWT, `setRaw` is a no-op (the JWT IS the storage), `trackActive` writes to a small Redis hash so single-session enforcement still works. `listAll` is omitted.
|
|
260
|
+
- **In-memory mock** — for integration tests. A `Map<string, { value, expiresAt }>` with a setInterval-based expiration sweep.
|
|
261
|
+
|
|
262
|
+
Call `registerSessionAdapter(...)` once at boot from `luckystack/server/index.ts`, BEFORE the first login request. Last-write-wins on subsequent calls (you can swap adapters in tests this way).
|
|
263
|
+
|
|
264
|
+
## `getSessionAdapter`
|
|
265
|
+
|
|
266
|
+
Reads the currently-active adapter. Defaults to `redisSessionAdapter` when no `registerSessionAdapter` call has happened. The default adapter is exported as `redisSessionAdapter` for cases where consumer code wants to compose around it (e.g. a wrapper adapter that delegates to Redis but also writes to an audit log).
|
|
267
|
+
|
|
268
|
+
## Why a separate broadcast on `updateSession`
|
|
269
|
+
|
|
270
|
+
When `saveSession` writes a `newUser: false` update — for instance an admin granting a user the `admin` flag from a settings page — the broadcast pushes the new session JSON to every socket already in the token room. The client-side `SessionProvider` listens for `socketEventNames.updateSession` and merges the payload into its in-memory session state.
|
|
271
|
+
|
|
272
|
+
This means:
|
|
273
|
+
|
|
274
|
+
- Permission changes apply instantly across all the user's open tabs.
|
|
275
|
+
- Profile updates (name, avatar) reflect in the navbar without a refresh.
|
|
276
|
+
- Custom session fields (e.g. `googleCalendarToken` set via `extraSessionFields`) flow to the client the same way.
|
|
277
|
+
|
|
278
|
+
The broadcast is gated on `io.sockets.adapter.rooms.has(token)` so a save targeting an offline user doesn't waste an emit.
|
|
279
|
+
|
|
280
|
+
## Related
|
|
281
|
+
|
|
282
|
+
- [`./logout-flow.md`](./logout-flow.md) — `logout` and `deleteSession`'s socket-walk.
|
|
283
|
+
- [`./hooks.md`](./hooks.md) — `preSessionCreate` / `postSessionCreate` / `preSessionDelete` / `postSessionDelete` / `preSessionRefresh` / `postSessionRefresh`.
|
|
284
|
+
- [`./credentials-auth.md`](./credentials-auth.md) — what calls `saveSession(token, user, true)`.
|
|
285
|
+
- [`./oauth-providers.md`](./oauth-providers.md) — `extraSessionFields` and module-augmentation of `BaseSessionLayout`.
|
|
286
|
+
- Architecture: [`/docs/ARCHITECTURE_SESSION.md`](../../../docs/ARCHITECTURE_SESSION.md).
|