lambder 3.3.3 → 3.4.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/Readme.md +31 -0
- package/dist/Lambder.d.ts +11 -1
- package/dist/Lambder.js +2 -1
- package/dist/LambderSessionController.d.ts +15 -2
- package/dist/LambderSessionController.js +33 -0
- package/dist/LambderSessionManager.d.ts +56 -2
- package/dist/LambderSessionManager.js +125 -32
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -275,6 +275,35 @@ lambder
|
|
|
275
275
|
|
|
276
276
|
See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
|
|
277
277
|
|
|
278
|
+
#### Keeping session data fresh (`dataRefresh`)
|
|
279
|
+
|
|
280
|
+
Session data often caches values derived from external state: roles, permissions, feature flags. Opt in to `dataRefresh` to give that data a shelf life. Every session read checks it, and once `ttlSeconds` have passed your `refresh` callback rebuilds the data, which is persisted onto the same session record: same tokens, same cookies, the session itself is untouched. Changes to the source of truth then reach every live session within `ttlSeconds`, with no mass session invalidation.
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
lambder.enableDdbSession({
|
|
284
|
+
tableName: "website-session",
|
|
285
|
+
tableRegion: "us-east-1",
|
|
286
|
+
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
|
|
287
|
+
dataRefresh: {
|
|
288
|
+
ttlSeconds: 600, // data is renewed at most every 10 minutes
|
|
289
|
+
refresh: async (session) => {
|
|
290
|
+
const user = await loadUser(session.data.userId);
|
|
291
|
+
if (!user || user.disabled) return null; // null ends the session
|
|
292
|
+
return buildSessionData(user);
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Semantics:
|
|
299
|
+
|
|
300
|
+
- The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
|
|
301
|
+
- Returning `null` deletes the session; the request is answered as session-expired.
|
|
302
|
+
- Thrown errors fail the request as a `LambderSessionDataRefreshError` and leave the session untouched (they are never mistaken for a logout). Catch inside and return `session.data` to explicitly serve stale instead.
|
|
303
|
+
- The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
|
|
304
|
+
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
305
|
+
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
306
|
+
|
|
278
307
|
#### Session Controller
|
|
279
308
|
|
|
280
309
|
Access the session controller with `lambder.getSessionController(ctx)`:
|
|
@@ -285,8 +314,10 @@ Access the session controller with `lambder.getSessionController(ctx)`:
|
|
|
285
314
|
| `fetchSession()` | Fetch & validate existing session (throws if not found) |
|
|
286
315
|
| `fetchSessionIfExists()` | Returns session or null |
|
|
287
316
|
| `updateSessionData(newData)` | Update session data in DDB |
|
|
317
|
+
| `refreshSessionData()` | Run the `dataRefresh` callback now, regardless of TTL |
|
|
288
318
|
| `endSession()` | End session, delete from DDB |
|
|
289
319
|
| `endSessionAll()` | End all sessions for this sessionKey (all devices) |
|
|
320
|
+
| `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
|
|
290
321
|
| `regenerateSession()` | Regenerate token (use after password change) |
|
|
291
322
|
|
|
292
323
|
### Type-Safe Templating (html / xml)
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
|
5
5
|
import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
|
|
6
6
|
import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
|
|
7
7
|
import { type LambderCorsConfig } from "./LambderCors.js";
|
|
8
|
+
import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
|
|
8
9
|
import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
|
|
9
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
10
11
|
import type { MergeContract } from "./LambderApiContract.js";
|
|
@@ -123,7 +124,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
123
124
|
private sessionCsrfCookieKey;
|
|
124
125
|
constructor(options?: LambderConstructorOptions);
|
|
125
126
|
enableCors(config: boolean | LambderCorsConfig): this;
|
|
126
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }: {
|
|
127
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
|
|
127
128
|
tableName: string;
|
|
128
129
|
tableRegion: string;
|
|
129
130
|
sessionSalt: string;
|
|
@@ -134,6 +135,15 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
134
135
|
cookie?: LambderSessionCookieOptions;
|
|
135
136
|
partitionKey?: string;
|
|
136
137
|
sortKey?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Opt-in freshness for session.data derived from external state
|
|
140
|
+
* (roles, permissions, feature flags...). Every session read
|
|
141
|
+
* renews data past its ttlSeconds via your refresh callback,
|
|
142
|
+
* persisting in place on the same record: same tokens, same
|
|
143
|
+
* cookies. Return null from refresh to end the session. See
|
|
144
|
+
* LambderSessionDataRefreshConfig for the exact semantics.
|
|
145
|
+
*/
|
|
146
|
+
dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
|
|
137
147
|
}): this;
|
|
138
148
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
|
|
139
149
|
setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
|
package/dist/Lambder.js
CHANGED
|
@@ -71,12 +71,13 @@ export default class Lambder {
|
|
|
71
71
|
this.corsConfig = config === true ? {} : (config === false ? null : config);
|
|
72
72
|
return this;
|
|
73
73
|
}
|
|
74
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }) {
|
|
74
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
|
|
75
75
|
this.lambderSessionManager = new LambderSessionManager({
|
|
76
76
|
tableName, tableRegion,
|
|
77
77
|
partitionKey: partitionKey ?? "pk",
|
|
78
78
|
sortKey: sortKey ?? "sk",
|
|
79
79
|
sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
|
|
80
|
+
dataRefresh,
|
|
80
81
|
});
|
|
81
82
|
this.sessionCookieOptions = cookie ?? {};
|
|
82
83
|
return this;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
|
|
2
2
|
import type LambderSessionManager from "./LambderSessionManager.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type LambderSessionContext } from "./LambderSessionManager.js";
|
|
4
4
|
export type LambderSessionCookieOptions = {
|
|
5
5
|
/**
|
|
6
6
|
* e.g. ".example.com" to share sessions across subdomains. Pass a function to
|
|
@@ -32,9 +32,22 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
32
32
|
createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
|
|
33
33
|
regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
|
|
34
34
|
fetchSession(): Promise<LambderSessionContext<TSessionData>>;
|
|
35
|
-
fetchSessionIfExists(): Promise<LambderSessionContext | null>;
|
|
35
|
+
fetchSessionIfExists(): Promise<LambderSessionContext<TSessionData> | null>;
|
|
36
36
|
isSessionValid(session: any): boolean;
|
|
37
37
|
updateSessionData(newData: any): Promise<LambderSessionContext>;
|
|
38
|
+
/**
|
|
39
|
+
* Force-runs the dataRefresh callback now (see enableDdbSession) and
|
|
40
|
+
* persists the result onto the current session. Returns the updated
|
|
41
|
+
* session, or null when the callback ended it: the record is deleted and
|
|
42
|
+
* the session cookies are cleared.
|
|
43
|
+
*/
|
|
44
|
+
refreshSessionData(): Promise<LambderSessionContext<TSessionData> | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Deletes every session of the given sessionKey (e.g. a user id): "log
|
|
47
|
+
* this subject out everywhere". Unlike endSessionAll it needs no fetched
|
|
48
|
+
* session and touches no cookies, so it works on any subject.
|
|
49
|
+
*/
|
|
50
|
+
deleteSessionAllByKey(sessionKey: string): Promise<void>;
|
|
38
51
|
endSession(): Promise<void>;
|
|
39
52
|
endSessionAll(): Promise<void>;
|
|
40
53
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
|
|
1
2
|
export default class LambderSessionController {
|
|
2
3
|
lambderSessionManager;
|
|
3
4
|
sessionTokenCookieKey;
|
|
@@ -90,6 +91,10 @@ export default class LambderSessionController {
|
|
|
90
91
|
return await this.fetchSession();
|
|
91
92
|
}
|
|
92
93
|
catch (err) {
|
|
94
|
+
// Missing or invalid sessions become null, but a failing
|
|
95
|
+
// dataRefresh callback must not masquerade as a logout.
|
|
96
|
+
if (err instanceof LambderSessionDataRefreshError)
|
|
97
|
+
throw err;
|
|
93
98
|
return null;
|
|
94
99
|
}
|
|
95
100
|
}
|
|
@@ -113,6 +118,34 @@ export default class LambderSessionController {
|
|
|
113
118
|
return this.ctx.session;
|
|
114
119
|
}
|
|
115
120
|
;
|
|
121
|
+
/**
|
|
122
|
+
* Force-runs the dataRefresh callback now (see enableDdbSession) and
|
|
123
|
+
* persists the result onto the current session. Returns the updated
|
|
124
|
+
* session, or null when the callback ended it: the record is deleted and
|
|
125
|
+
* the session cookies are cleared.
|
|
126
|
+
*/
|
|
127
|
+
async refreshSessionData() {
|
|
128
|
+
if (!this.ctx.session)
|
|
129
|
+
throw new Error("Session not found.");
|
|
130
|
+
const refreshed = await this.lambderSessionManager.refreshSessionData(this.ctx.session);
|
|
131
|
+
if (!refreshed) {
|
|
132
|
+
this.clearSessionCookies();
|
|
133
|
+
this.ctx.session = null;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
this.ctx.session = refreshed;
|
|
137
|
+
return this.ctx.session;
|
|
138
|
+
}
|
|
139
|
+
;
|
|
140
|
+
/**
|
|
141
|
+
* Deletes every session of the given sessionKey (e.g. a user id): "log
|
|
142
|
+
* this subject out everywhere". Unlike endSessionAll it needs no fetched
|
|
143
|
+
* session and touches no cookies, so it works on any subject.
|
|
144
|
+
*/
|
|
145
|
+
async deleteSessionAllByKey(sessionKey) {
|
|
146
|
+
await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
|
|
147
|
+
}
|
|
148
|
+
;
|
|
116
149
|
async endSession() {
|
|
117
150
|
if (!this.ctx.session)
|
|
118
151
|
throw new Error("Session not found.");
|
|
@@ -8,7 +8,43 @@ export type LambderSessionContext<SessionData = any> = {
|
|
|
8
8
|
expiresAt: number;
|
|
9
9
|
lastAccessedAt: number;
|
|
10
10
|
ttlInSeconds: number;
|
|
11
|
+
/**
|
|
12
|
+
* When `data` must be renewed via the dataRefresh callback (epoch seconds).
|
|
13
|
+
* Only present when dataRefresh is configured; independent of the
|
|
14
|
+
* session's own expiresAt.
|
|
15
|
+
*/
|
|
16
|
+
dataExpiresAt?: number;
|
|
11
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Opt-in freshness for session.data that is derived from external state
|
|
20
|
+
* (roles, permissions, feature flags...). When configured, every session read
|
|
21
|
+
* checks dataExpiresAt and calls `refresh` past it, persisting the result
|
|
22
|
+
* onto the same session record: same tokens, same cookies, the session
|
|
23
|
+
* itself is untouched. The refresh write and the sliding-expiration write
|
|
24
|
+
* share a single DynamoDB put when both are due.
|
|
25
|
+
*/
|
|
26
|
+
export type LambderSessionDataRefreshConfig<SessionData = any> = {
|
|
27
|
+
/** Seconds session.data stays valid before refresh() runs on read. */
|
|
28
|
+
ttlSeconds: number;
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild session.data from its source of truth. Must be a pure
|
|
31
|
+
* derivation (concurrent reads may run it in parallel; last write wins).
|
|
32
|
+
* Return null to end the session: the record is deleted and the read
|
|
33
|
+
* reports no session. Thrown errors fail the read as a
|
|
34
|
+
* LambderSessionDataRefreshError and leave the session untouched; catch
|
|
35
|
+
* inside and return session.data to explicitly serve stale instead.
|
|
36
|
+
*/
|
|
37
|
+
refresh: (session: LambderSessionContext<SessionData>) => Promise<SessionData | null>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
41
|
+
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
42
|
+
* or invalid sessions but rethrows this, otherwise a transient failure in
|
|
43
|
+
* the refresh source would masquerade as a logout.
|
|
44
|
+
*/
|
|
45
|
+
export declare class LambderSessionDataRefreshError extends Error {
|
|
46
|
+
constructor(cause: unknown);
|
|
47
|
+
}
|
|
12
48
|
export default class LambderSessionManager {
|
|
13
49
|
private tableName;
|
|
14
50
|
private sessionSalt;
|
|
@@ -17,7 +53,8 @@ export default class LambderSessionManager {
|
|
|
17
53
|
private ddbDocumentClient;
|
|
18
54
|
private enableSlidingExpiration;
|
|
19
55
|
private slidingWriteIntervalSeconds;
|
|
20
|
-
|
|
56
|
+
private dataRefresh;
|
|
57
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, }: {
|
|
21
58
|
tableName: string;
|
|
22
59
|
tableRegion: string;
|
|
23
60
|
partitionKey: string;
|
|
@@ -25,6 +62,7 @@ export default class LambderSessionManager {
|
|
|
25
62
|
sessionSalt: string;
|
|
26
63
|
enableSlidingExpiration?: boolean;
|
|
27
64
|
slidingWriteIntervalSeconds?: number;
|
|
65
|
+
dataRefresh?: LambderSessionDataRefreshConfig;
|
|
28
66
|
});
|
|
29
67
|
private sessionUserKeyHasher;
|
|
30
68
|
private constantTimeCompare;
|
|
@@ -33,11 +71,27 @@ export default class LambderSessionManager {
|
|
|
33
71
|
private ddbDeleteItem;
|
|
34
72
|
private ddbQueryAllByPartitionKey;
|
|
35
73
|
private ddbDeleteAllByPartitionKey;
|
|
36
|
-
createSession(sessionKey: string, data?: any, ttlInSeconds?: number
|
|
74
|
+
createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
|
|
75
|
+
/** Carries an existing data freshness stamp over (used by regenerateSession). */
|
|
76
|
+
dataExpiresAt?: number;
|
|
77
|
+
}): Promise<LambderSessionContext>;
|
|
37
78
|
updateSessionData(session: LambderSessionContext, newData?: any): Promise<LambderSessionContext>;
|
|
38
79
|
getSession(sessionToken: string): Promise<LambderSessionContext | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Runs the dataRefresh callback now, regardless of dataExpiresAt, and
|
|
82
|
+
* persists the result onto the same record. Returns the updated session,
|
|
83
|
+
* or null when the callback ended it (the record is deleted). Requires
|
|
84
|
+
* dataRefresh to be configured.
|
|
85
|
+
*/
|
|
86
|
+
refreshSessionData(session: LambderSessionContext): Promise<LambderSessionContext | null>;
|
|
39
87
|
isSessionValid(session: any, sessionToken: any, csrfToken: any, skipCsrfTokenCheck?: boolean): boolean;
|
|
40
88
|
deleteSession(session: Record<string, any>): Promise<boolean>;
|
|
41
89
|
deleteSessionAll(session: Record<string, any>): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Deletes every session created for the given sessionKey (e.g. a user
|
|
92
|
+
* id): "log this subject out everywhere", without needing a fetched
|
|
93
|
+
* session record.
|
|
94
|
+
*/
|
|
95
|
+
deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
|
|
42
96
|
regenerateSession(session: LambderSessionContext): Promise<LambderSessionContext>;
|
|
43
97
|
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
3
|
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
|
+
/**
|
|
5
|
+
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
6
|
+
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
7
|
+
* or invalid sessions but rethrows this, otherwise a transient failure in
|
|
8
|
+
* the refresh source would masquerade as a logout.
|
|
9
|
+
*/
|
|
10
|
+
export class LambderSessionDataRefreshError extends Error {
|
|
11
|
+
constructor(cause) {
|
|
12
|
+
super(`Session dataRefresh failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
13
|
+
this.name = "LambderSessionDataRefreshError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
4
16
|
export default class LambderSessionManager {
|
|
5
17
|
tableName;
|
|
6
18
|
sessionSalt;
|
|
@@ -9,13 +21,15 @@ export default class LambderSessionManager {
|
|
|
9
21
|
ddbDocumentClient;
|
|
10
22
|
enableSlidingExpiration;
|
|
11
23
|
slidingWriteIntervalSeconds;
|
|
12
|
-
|
|
24
|
+
dataRefresh;
|
|
25
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, }) {
|
|
13
26
|
this.tableName = tableName;
|
|
14
27
|
this.sessionSalt = sessionSalt;
|
|
15
28
|
this.partitionKey = partitionKey;
|
|
16
29
|
this.sortKey = sortKey;
|
|
17
30
|
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
18
31
|
this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
|
|
32
|
+
this.dataRefresh = dataRefresh ?? null;
|
|
19
33
|
const ddbClient = new DynamoDBClient({ region: tableRegion });
|
|
20
34
|
this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
|
|
21
35
|
}
|
|
@@ -74,7 +88,7 @@ export default class LambderSessionManager {
|
|
|
74
88
|
}));
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
|
-
async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60) {
|
|
91
|
+
async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60, options) {
|
|
78
92
|
const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
|
|
79
93
|
const sessionSortKey = crypto.randomBytes(32).toString("hex");
|
|
80
94
|
const sessionToken = `${sessionKeyHash}:${sessionSortKey}`;
|
|
@@ -87,7 +101,8 @@ export default class LambderSessionManager {
|
|
|
87
101
|
[this.sortKey]: sessionSortKey,
|
|
88
102
|
sessionToken, csrfToken,
|
|
89
103
|
sessionKey, data,
|
|
90
|
-
createdAt, lastAccessedAt, expiresAt, ttlInSeconds
|
|
104
|
+
createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
|
|
105
|
+
...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
|
|
91
106
|
};
|
|
92
107
|
await this.ddbPutItem(session);
|
|
93
108
|
return session;
|
|
@@ -97,6 +112,10 @@ export default class LambderSessionManager {
|
|
|
97
112
|
throw new Error("Invalid session");
|
|
98
113
|
session.data = newData;
|
|
99
114
|
session.lastAccessedAt = Math.floor(Date.now() / 1000);
|
|
115
|
+
// Explicitly written data is fresh by definition.
|
|
116
|
+
if (this.dataRefresh) {
|
|
117
|
+
session.dataExpiresAt = session.lastAccessedAt + this.dataRefresh.ttlSeconds;
|
|
118
|
+
}
|
|
100
119
|
// Update expiration if sliding expiration is enabled
|
|
101
120
|
if (this.enableSlidingExpiration) {
|
|
102
121
|
session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
|
|
@@ -108,43 +127,105 @@ export default class LambderSessionManager {
|
|
|
108
127
|
const [sessionKeyHash, sessionSortKey] = sessionToken.split(":");
|
|
109
128
|
if (!sessionKeyHash || !sessionSortKey)
|
|
110
129
|
return null;
|
|
130
|
+
let session;
|
|
111
131
|
try {
|
|
112
|
-
|
|
132
|
+
session = await this.ddbGetItem({
|
|
113
133
|
[this.partitionKey]: sessionKeyHash,
|
|
114
134
|
[this.sortKey]: sessionSortKey
|
|
115
135
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// Use constant error response to prevent timing attacks
|
|
141
|
+
if (!session)
|
|
142
|
+
return null;
|
|
143
|
+
if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
|
|
144
|
+
return null;
|
|
145
|
+
if (!session.csrfToken)
|
|
146
|
+
return null;
|
|
147
|
+
if (!session.sessionKey)
|
|
148
|
+
return null;
|
|
149
|
+
if (!session.createdAt)
|
|
150
|
+
return null;
|
|
151
|
+
if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
|
|
152
|
+
return null;
|
|
153
|
+
const now = Math.floor(Date.now() / 1000);
|
|
154
|
+
let needsWrite = false;
|
|
155
|
+
// Renew session.data once its shelf life has passed (opt-in
|
|
156
|
+
// dataRefresh). Records from before the feature was enabled have no
|
|
157
|
+
// dataExpiresAt, so they renew on first read.
|
|
158
|
+
if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
|
|
159
|
+
let newData;
|
|
160
|
+
try {
|
|
161
|
+
newData = await this.dataRefresh.refresh(session);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
// A failing refresh must fail this read, not masquerade as a
|
|
165
|
+
// missing session or silently serve stale data.
|
|
166
|
+
throw new LambderSessionDataRefreshError(err);
|
|
167
|
+
}
|
|
168
|
+
if (newData === null) {
|
|
169
|
+
await this.deleteSession(session);
|
|
128
170
|
return null;
|
|
129
|
-
// Update last accessed time if sliding expiration is enabled.
|
|
130
|
-
// Throttled: skip the DynamoDB write when the session was refreshed
|
|
131
|
-
// recently, to avoid a write on every request.
|
|
132
|
-
if (this.enableSlidingExpiration) {
|
|
133
|
-
const now = Math.floor(Date.now() / 1000);
|
|
134
|
-
const minInterval = this.slidingWriteIntervalSeconds
|
|
135
|
-
?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
|
|
136
|
-
if (now - (session.lastAccessedAt || 0) >= minInterval) {
|
|
137
|
-
session.lastAccessedAt = now;
|
|
138
|
-
session.expiresAt = now + session.ttlInSeconds;
|
|
139
|
-
// Wait for the update to ensure it persists before Lambda freezes
|
|
140
|
-
await this.ddbPutItem(session).catch(() => { });
|
|
141
|
-
}
|
|
142
171
|
}
|
|
143
|
-
|
|
172
|
+
session.data = newData;
|
|
173
|
+
session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
|
|
174
|
+
needsWrite = true;
|
|
175
|
+
}
|
|
176
|
+
// Update last accessed time if sliding expiration is enabled.
|
|
177
|
+
// Throttled: skip the DynamoDB write when the session was refreshed
|
|
178
|
+
// recently, to avoid a write on every request. A due data renewal
|
|
179
|
+
// above forces the write anyway, so both updates share one put.
|
|
180
|
+
if (this.enableSlidingExpiration) {
|
|
181
|
+
const minInterval = this.slidingWriteIntervalSeconds
|
|
182
|
+
?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
|
|
183
|
+
if (needsWrite || now - (session.lastAccessedAt || 0) >= minInterval) {
|
|
184
|
+
session.lastAccessedAt = now;
|
|
185
|
+
session.expiresAt = now + session.ttlInSeconds;
|
|
186
|
+
needsWrite = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (needsWrite) {
|
|
190
|
+
// Wait for the update to ensure it persists before Lambda freezes.
|
|
191
|
+
// A failed put is not fatal: the data served is fresh, and an
|
|
192
|
+
// unpersisted renewal simply runs again on the next read.
|
|
193
|
+
await this.ddbPutItem(session).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
return session;
|
|
196
|
+
}
|
|
197
|
+
;
|
|
198
|
+
/**
|
|
199
|
+
* Runs the dataRefresh callback now, regardless of dataExpiresAt, and
|
|
200
|
+
* persists the result onto the same record. Returns the updated session,
|
|
201
|
+
* or null when the callback ended it (the record is deleted). Requires
|
|
202
|
+
* dataRefresh to be configured.
|
|
203
|
+
*/
|
|
204
|
+
async refreshSessionData(session) {
|
|
205
|
+
if (!this.dataRefresh)
|
|
206
|
+
throw new Error("dataRefresh is not configured. Pass dataRefresh to enableDdbSession(...) to enable.");
|
|
207
|
+
if (!session)
|
|
208
|
+
throw new Error("Invalid session");
|
|
209
|
+
let newData;
|
|
210
|
+
try {
|
|
211
|
+
newData = await this.dataRefresh.refresh(session);
|
|
144
212
|
}
|
|
145
213
|
catch (err) {
|
|
214
|
+
throw new LambderSessionDataRefreshError(err);
|
|
215
|
+
}
|
|
216
|
+
if (newData === null) {
|
|
217
|
+
await this.deleteSession(session);
|
|
146
218
|
return null;
|
|
147
219
|
}
|
|
220
|
+
const now = Math.floor(Date.now() / 1000);
|
|
221
|
+
session.data = newData;
|
|
222
|
+
session.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
|
|
223
|
+
session.lastAccessedAt = now;
|
|
224
|
+
if (this.enableSlidingExpiration) {
|
|
225
|
+
session.expiresAt = now + session.ttlInSeconds;
|
|
226
|
+
}
|
|
227
|
+
await this.ddbPutItem(session);
|
|
228
|
+
return session;
|
|
148
229
|
}
|
|
149
230
|
;
|
|
150
231
|
isSessionValid(session, sessionToken, csrfToken, skipCsrfTokenCheck = false) {
|
|
@@ -183,13 +264,25 @@ export default class LambderSessionManager {
|
|
|
183
264
|
return true;
|
|
184
265
|
}
|
|
185
266
|
;
|
|
267
|
+
/**
|
|
268
|
+
* Deletes every session created for the given sessionKey (e.g. a user
|
|
269
|
+
* id): "log this subject out everywhere", without needing a fetched
|
|
270
|
+
* session record.
|
|
271
|
+
*/
|
|
272
|
+
async deleteSessionAllByKey(sessionKey) {
|
|
273
|
+
await this.ddbDeleteAllByPartitionKey(this.sessionUserKeyHasher(sessionKey));
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
;
|
|
186
277
|
async regenerateSession(session) {
|
|
187
278
|
if (!session)
|
|
188
279
|
throw new Error("Invalid session");
|
|
189
280
|
// Delete old session
|
|
190
281
|
await this.deleteSession(session);
|
|
191
|
-
// Create new session with same sessionKey and data but new tokens
|
|
192
|
-
|
|
282
|
+
// Create new session with same sessionKey and data but new tokens.
|
|
283
|
+
// The data freshness stamp carries over: rotating tokens must not
|
|
284
|
+
// extend how long dataRefresh-managed data may stay unrenewed.
|
|
285
|
+
return await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, session.dataExpiresAt !== undefined ? { dataExpiresAt: session.dataExpiresAt } : undefined);
|
|
193
286
|
}
|
|
194
287
|
}
|
|
195
288
|
;
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type { LambderRouteMatcher, LambderCorsConfig, LambderConstructorOptions,
|
|
|
16
16
|
export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
17
17
|
export type { LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
18
18
|
export type { LambderSessionCookieOptions } from "./LambderSessionController.js";
|
|
19
|
-
export type { LambderSessionContext } from "./LambderSessionManager.js";
|
|
19
|
+
export type { LambderSessionContext, LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
|
|
20
|
+
export { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
|
|
20
21
|
export { LambderDdbCache } from "./LambderDdbCache.js";
|
|
21
22
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./LambderDdbCache.js";
|
|
22
23
|
export { LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
14
14
|
export { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
|
|
15
15
|
// Public file serving
|
|
16
16
|
export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
17
|
+
export { LambderSessionDataRefreshError } from "./LambderSessionManager.js";
|
|
17
18
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
18
19
|
export { LambderDdbCache } from "./LambderDdbCache.js";
|
|
19
20
|
// DynamoDB-backed fixed-window rate limiter (standalone, server-only)
|