lambder 7.3.1 → 8.0.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 +933 -3
- package/README.md +41 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +140 -75
- package/dist/core/Lambder.js +347 -227
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +1 -1
- package/dist/mock.js +2 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +21 -23
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +12 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LambderWebCrypto } from "./LambderSessionCrypto.js";
|
|
2
2
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
3
3
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
|
+
import { canonicalJson } from "../shared/util/canonicalJson.js";
|
|
4
5
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
5
6
|
/**
|
|
6
7
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
@@ -29,35 +30,43 @@ export class LambderSessionReadError extends Error {
|
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
32
|
* The longest either half of a session token may be. A minted token is two
|
|
32
|
-
* 64-character hex halves
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the previous one, and LambderSessionCrypto exposes no length to read.
|
|
33
|
+
* 64-character hex halves; sixteen times that leaves a custom
|
|
34
|
+
* LambderSessionCrypto free to mint longer hashes or secrets, and leaves
|
|
35
|
+
* LambderPlainSessionCrypto (which hex-encodes rather than hashes) room for a
|
|
36
|
+
* session key and salt of several hundred characters. The bound is not
|
|
37
|
+
* derived from the crypto: LambderSessionCrypto exposes no length, and tying
|
|
38
|
+
* it to the configured crypto would reject every session a previously
|
|
39
|
+
* configured one minted.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* every request.
|
|
41
|
+
* What matters is staying under DynamoDB's 2048-byte partition key limit. A
|
|
42
|
+
* cookie may carry 4000, so without a bound a planted oversized cookie
|
|
43
|
+
* reaches the store as a key it cannot take, the read throws, and a live
|
|
44
|
+
* session beside it answers 500 on every request.
|
|
46
45
|
*/
|
|
47
46
|
const MAX_SESSION_TOKEN_HALF_CHARS = 1024;
|
|
48
|
-
/**
|
|
47
|
+
/** How many store writes one "every session of a subject" operation runs at once. */
|
|
48
|
+
const SUBJECT_WRITE_CONCURRENCY = 16;
|
|
49
|
+
/**
|
|
50
|
+
* How many times deleting every session of a subject lists them in all,
|
|
51
|
+
* when each pass finds a listed record already gone (see deleteAllUnder).
|
|
52
|
+
* Each further pass needs a rotation to complete inside the previous pass's
|
|
53
|
+
* gap between listing and deleting; the bound keeps a client rotating in a
|
|
54
|
+
* tight loop from holding the call open, and a call that reaches it answers
|
|
55
|
+
* false.
|
|
56
|
+
*/
|
|
57
|
+
const SUBJECT_DELETE_MAX_PASSES = 4;
|
|
58
|
+
/** Hex in either case: what every LambderSessionCrypto's hmacSha256Hex (the first half) and randomHex (the second) produce, whichever case a custom one picks. */
|
|
49
59
|
const SESSION_TOKEN_HALF_PATTERN = /^[0-9a-fA-F]+$/;
|
|
50
60
|
/**
|
|
51
61
|
* Whether a string has the shape a minted session token has:
|
|
52
62
|
* `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
|
|
53
63
|
*
|
|
54
|
-
* It lives beside the code that mints and splits
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* writer of these cookies is the code that mints them.
|
|
64
|
+
* It lives beside the code that mints and splits the format, not the cookie
|
|
65
|
+
* reader, so the ceiling belongs to the model and anyone handing
|
|
66
|
+
* lookupSession a token they did not mint can ask it too. The session
|
|
67
|
+
* controller checks every candidate cookie with it before any store read, so
|
|
68
|
+
* a malformed one is "no session", never a read error. No legitimate cookie
|
|
69
|
+
* fails it, since only the minting code writes them.
|
|
61
70
|
*/
|
|
62
71
|
export const isMintedSessionToken = (token) => {
|
|
63
72
|
const halves = token.split(":");
|
|
@@ -100,7 +109,7 @@ export default class LambderSessionManager {
|
|
|
100
109
|
this.crypto = crypto ?? new LambderWebCrypto();
|
|
101
110
|
this.assertCryptoFitsStore(store);
|
|
102
111
|
if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
|
|
103
|
-
throw new Error("Lambder: session sessionSalt is empty. It
|
|
112
|
+
throw new Error("Lambder: session sessionSalt is empty. It keys the hash that partitions the store, so it has to be a real, stable secret.");
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
/** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
|
|
@@ -122,24 +131,19 @@ export default class LambderSessionManager {
|
|
|
122
131
|
this.store = store;
|
|
123
132
|
}
|
|
124
133
|
/**
|
|
125
|
-
* The salted partition hash of a sessionKey:
|
|
126
|
-
*
|
|
134
|
+
* The salted partition hash of a sessionKey: HMAC-SHA256 with the salt
|
|
135
|
+
* as the key and the sessionKey as the message.
|
|
127
136
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* collide when the difference between their salts can be absorbed into a
|
|
136
|
-
* sessionKey ("ab" + "cd" and "a" + "bcd" hash alike). Two deployments
|
|
137
|
-
* over one table must therefore not stand in a prefix relationship over
|
|
138
|
-
* their salts. Separate tables, or salts that are independent random
|
|
139
|
-
* strings, both rule it out.
|
|
137
|
+
* Keyed rather than hashed over the two run together, because a plain
|
|
138
|
+
* concatenation cannot tell where the sessionKey ends and the salt
|
|
139
|
+
* begins: "ab" + "cd" and "a" + "bcd" would hash alike, so two
|
|
140
|
+
* deployments sharing one table could collide whenever the difference
|
|
141
|
+
* between their salts fits into a sessionKey. With the salt as the HMAC
|
|
142
|
+
* key the two inputs never meet in one string, so no choice of salts
|
|
143
|
+
* lets one deployment's subject land in another's partition.
|
|
140
144
|
*/
|
|
141
145
|
sessionKeyHashOf(sessionKey) {
|
|
142
|
-
return this.crypto.
|
|
146
|
+
return this.crypto.hmacSha256Hex(this.sessionSalt, sessionKey);
|
|
143
147
|
}
|
|
144
148
|
/**
|
|
145
149
|
* At-rest hash for the bearer secrets (session sort-key secret, CSRF
|
|
@@ -156,11 +160,11 @@ export default class LambderSessionManager {
|
|
|
156
160
|
// unparsed environment variable would write a record nothing ever
|
|
157
161
|
// retires and no read ever accepts.
|
|
158
162
|
assertPositiveInteger(ttlInSeconds, "createSession ttlInSeconds");
|
|
159
|
-
// Refused
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
163
|
+
// Refused like a NaN TTL: lookupSession rejects a record with no
|
|
164
|
+
// sessionKey on every read (a session has to name its subject), so
|
|
165
|
+
// the caller would get a valid-looking cookie pair for a session
|
|
166
|
+
// nobody can use, and every later request would read as a silent
|
|
167
|
+
// logout.
|
|
164
168
|
if (!sessionKey)
|
|
165
169
|
throw new Error("Lambder: createSession sessionKey is empty. It names the subject the session belongs to and partitions the store, so an empty one writes a record no read accepts.");
|
|
166
170
|
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
@@ -179,33 +183,67 @@ export default class LambderSessionManager {
|
|
|
179
183
|
sessionKey, data,
|
|
180
184
|
createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
|
|
181
185
|
...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
|
|
186
|
+
dataVersion: 0,
|
|
182
187
|
};
|
|
183
|
-
await this.store.
|
|
188
|
+
await this.store.create(session);
|
|
184
189
|
return { session, sessionToken, csrfToken };
|
|
185
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Writes new session data onto the record, and nothing else: the expiry
|
|
193
|
+
* slides only in renewSession, which also re-issues the cookies, so a
|
|
194
|
+
* data write never moves the record's expiry away from the browser's.
|
|
195
|
+
* Returns the updated record, or null when the session was ended in
|
|
196
|
+
* between (the write does not bring it back).
|
|
197
|
+
*
|
|
198
|
+
* With dataRefresh, the write leaves dataExpiresAt where it is: only the
|
|
199
|
+
* dataRefresh callback's output is stamped fresh. The data an app writes
|
|
200
|
+
* is almost always the session's own with a field changed, still carrying
|
|
201
|
+
* whatever the callback derived last, so a write that pushed the deadline
|
|
202
|
+
* would let an app writing more often than ttlSeconds never refresh at
|
|
203
|
+
* all, and would cancel the due start regenerateSession gives a rotated
|
|
204
|
+
* session. The write is still conditioned on the dataVersion this record
|
|
205
|
+
* was read with. If that moved in between (expireSessionDataAllByKey
|
|
206
|
+
* marked the data stale, or another request refreshed it, possibly
|
|
207
|
+
* applying a revocation already), the data is written and marked due, so
|
|
208
|
+
* a revocation that landed during this request is not undone by data
|
|
209
|
+
* derived before it.
|
|
210
|
+
*
|
|
211
|
+
* The returned record carries the dataVersion this write produced only
|
|
212
|
+
* when a conditioned write applied, which is then exactly one past the
|
|
213
|
+
* version it named. Otherwise it keeps the version it was read with,
|
|
214
|
+
* which the store has already passed, so a later conditioned write from
|
|
215
|
+
* the same request answers "stale" and lands marked due: the safe side.
|
|
216
|
+
*/
|
|
186
217
|
async updateSessionData(session, newData) {
|
|
187
218
|
if (!session)
|
|
188
219
|
throw new Error("Invalid session");
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
session.dataExpiresAt = session.lastAccessedAt + this.dataRefresh.ttlSeconds;
|
|
220
|
+
const { sessionKeyHash, secretHash } = session;
|
|
221
|
+
if (!this.dataRefresh) {
|
|
222
|
+
const result = await this.store.update(sessionKeyHash, secretHash, { data: newData });
|
|
223
|
+
return result === "missing" ? null : { ...session, data: newData };
|
|
194
224
|
}
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
session
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
225
|
+
const result = await this.store.update(sessionKeyHash, secretHash, { data: newData }, { dataVersion: session.dataVersion });
|
|
226
|
+
if (result === "updated")
|
|
227
|
+
return { ...session, data: newData, dataVersion: session.dataVersion + 1 };
|
|
228
|
+
if (result === "missing")
|
|
229
|
+
return null;
|
|
230
|
+
// The data or its deadline was written in between: a revocation was
|
|
231
|
+
// marked, or another request refreshed the data, possibly applying
|
|
232
|
+
// that revocation already. The app's data lands, and is marked due,
|
|
233
|
+
// so the next read runs it through dataRefresh again rather than
|
|
234
|
+
// serving what this request derived before the change.
|
|
235
|
+
const now = Math.floor(Date.now() / 1000);
|
|
236
|
+
if (await this.store.update(sessionKeyHash, secretHash, { data: newData, dataExpiresAt: now }) === "missing")
|
|
237
|
+
return null;
|
|
238
|
+
return { ...session, data: newData, dataExpiresAt: now };
|
|
201
239
|
}
|
|
202
240
|
/**
|
|
203
241
|
* The record a token names, read and structurally checked, with nothing
|
|
204
|
-
* renewed.
|
|
205
|
-
* cookies can decide which
|
|
242
|
+
* renewed. Kept apart from renewSession so a caller weighing several
|
|
243
|
+
* candidate cookies can decide which is this visitor's BEFORE anything is
|
|
206
244
|
* written on their behalf: renewing slides an expiry and may run the
|
|
207
245
|
* app's dataRefresh callback, and a cookie a sibling host planted must
|
|
208
|
-
*
|
|
246
|
+
* get neither from the victim's traffic.
|
|
209
247
|
*/
|
|
210
248
|
async lookupSession(sessionToken) {
|
|
211
249
|
const [sessionKeyHash, secret] = sessionToken.split(":");
|
|
@@ -213,9 +251,9 @@ export default class LambderSessionManager {
|
|
|
213
251
|
return null;
|
|
214
252
|
const secretHash = await this.hashToken(secret);
|
|
215
253
|
// A store read failure propagates typed: null means "no such
|
|
216
|
-
// session", which callers
|
|
217
|
-
//
|
|
218
|
-
//
|
|
254
|
+
// session", which callers answer as sessionExpired, clearing the
|
|
255
|
+
// client's session cookies. A transient infra error must surface as a
|
|
256
|
+
// 500, not force a logout.
|
|
219
257
|
let session;
|
|
220
258
|
try {
|
|
221
259
|
// The lookup itself proves possession of the raw secret: the
|
|
@@ -227,12 +265,11 @@ export default class LambderSessionManager {
|
|
|
227
265
|
}
|
|
228
266
|
if (!session)
|
|
229
267
|
return null;
|
|
230
|
-
// The record has to be the one
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// comparison and no hashing.
|
|
268
|
+
// The record has to be the one asked for. A correct store answers
|
|
269
|
+
// with the item under the two keys it was given; a store that does
|
|
270
|
+
// not (a loosely keyed cache, a query missing its partition) stops
|
|
271
|
+
// here instead of handing back somebody else's session. The hashes
|
|
272
|
+
// are in hand, so this costs a comparison and no hashing.
|
|
236
273
|
if (!this.crypto.constantTimeEqual(session.sessionKeyHash ?? "", sessionKeyHash))
|
|
237
274
|
return null;
|
|
238
275
|
if (!this.crypto.constantTimeEqual(session.secretHash ?? "", secretHash))
|
|
@@ -243,6 +280,10 @@ export default class LambderSessionManager {
|
|
|
243
280
|
return null;
|
|
244
281
|
if (!session.createdAt)
|
|
245
282
|
return null;
|
|
283
|
+
// Every conditioned write names it and renewal adds to it, so a store
|
|
284
|
+
// that dropped it would turn each into a NaN version no write matches.
|
|
285
|
+
if (typeof session.dataVersion !== "number")
|
|
286
|
+
return null;
|
|
246
287
|
// Expiry is enforced here, on every read, because the store contract
|
|
247
288
|
// allows a record past its expiresAt: a DynamoDB TTL deletes within
|
|
248
289
|
// days rather than at the second, and a store over a plain table
|
|
@@ -255,15 +296,17 @@ export default class LambderSessionManager {
|
|
|
255
296
|
/**
|
|
256
297
|
* The renewal half of a session read: the dataRefresh callback once its
|
|
257
298
|
* shelf life has passed, and the sliding-expiration write. Returns null
|
|
258
|
-
* when
|
|
259
|
-
*
|
|
299
|
+
* when the session is over: a refresh said so (a deleted or disabled
|
|
300
|
+
* login), or the record was deleted while this request read it (a
|
|
301
|
+
* logout, a password change), which a renewal must not undo.
|
|
260
302
|
*/
|
|
261
303
|
async renewSession(session) {
|
|
262
304
|
const now = Math.floor(Date.now() / 1000);
|
|
263
|
-
|
|
305
|
+
const refreshed = {};
|
|
306
|
+
const slid = {};
|
|
264
307
|
// Renew session.data once its shelf life has passed (opt-in
|
|
265
|
-
// dataRefresh).
|
|
266
|
-
// dataExpiresAt, so
|
|
308
|
+
// dataRefresh). A record created while dataRefresh was off has no
|
|
309
|
+
// dataExpiresAt, so it renews on its first read.
|
|
267
310
|
if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
|
|
268
311
|
let newData;
|
|
269
312
|
try {
|
|
@@ -278,45 +321,58 @@ export default class LambderSessionManager {
|
|
|
278
321
|
await this.deleteSession(session);
|
|
279
322
|
return null;
|
|
280
323
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
needsWrite = true;
|
|
324
|
+
refreshed.data = newData;
|
|
325
|
+
refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
|
|
284
326
|
}
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// above forces the write anyway, so both updates share one put.
|
|
327
|
+
// Sliding expiration, throttled: no store write while lastAccessedAt
|
|
328
|
+
// is recent, to avoid a write on every request. A due data renewal
|
|
329
|
+
// above forces the write, so both updates share one write.
|
|
289
330
|
if (this.enableSlidingExpiration) {
|
|
290
331
|
const minInterval = this.slidingWriteIntervalSeconds
|
|
291
332
|
?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
needsWrite = true;
|
|
333
|
+
if (refreshed.data !== undefined || now - (session.lastAccessedAt || 0) >= minInterval) {
|
|
334
|
+
slid.lastAccessedAt = now;
|
|
335
|
+
slid.expiresAt = now + session.ttlInSeconds;
|
|
296
336
|
}
|
|
297
337
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
338
|
+
const renewed = { ...session, ...refreshed, ...slid };
|
|
339
|
+
if (refreshed.data === undefined && slid.expiresAt === undefined)
|
|
340
|
+
return renewed;
|
|
341
|
+
// Awaited, so it persists before Lambda freezes. A failing write is
|
|
342
|
+
// not fatal: the refreshed data is served, the expiry stays where the
|
|
343
|
+
// cookies have it, and an unpersisted renewal runs again on the next
|
|
344
|
+
// read. It is logged because a store failing
|
|
345
|
+
// every renewal write silently ends every session at its creation
|
|
346
|
+
// TTL, visible otherwise only as users signed out too soon. The log
|
|
347
|
+
// carries the reason only, never anything identifying the session.
|
|
348
|
+
try {
|
|
349
|
+
const result = await this.store.update(session.sessionKeyHash, session.secretHash, { ...refreshed, ...slid }, refreshed.data !== undefined ? { dataVersion: session.dataVersion } : undefined);
|
|
350
|
+
if (result === "missing")
|
|
351
|
+
return null;
|
|
352
|
+
// Refreshed data that landed moved the version exactly once past
|
|
353
|
+
// the one the write named (see updateSessionData); a slide alone
|
|
354
|
+
// leaves it.
|
|
355
|
+
if (result === "updated")
|
|
356
|
+
return refreshed.data !== undefined ? { ...renewed, dataVersion: session.dataVersion + 1 } : renewed;
|
|
357
|
+
// Written over by a newer write, or marked stale, while the
|
|
358
|
+
// refresh ran: that write stands, and the expiry still slides.
|
|
359
|
+
if (slid.expiresAt !== undefined && await this.store.update(session.sessionKeyHash, session.secretHash, slid) === "missing")
|
|
360
|
+
return null;
|
|
311
361
|
}
|
|
312
|
-
|
|
362
|
+
catch (err) {
|
|
363
|
+
console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
|
|
364
|
+
return { ...session, ...refreshed };
|
|
365
|
+
}
|
|
366
|
+
return renewed;
|
|
313
367
|
}
|
|
314
368
|
;
|
|
315
369
|
/**
|
|
316
|
-
* Runs the dataRefresh callback
|
|
317
|
-
* persists the result onto the same record
|
|
318
|
-
*
|
|
319
|
-
*
|
|
370
|
+
* Runs the dataRefresh callback immediately, regardless of
|
|
371
|
+
* dataExpiresAt, and persists the result onto the same record, over the
|
|
372
|
+
* data it was computed from only (see LambderSessionDataRefreshConfig).
|
|
373
|
+
* Returns the refreshed session, or null when the callback ended it (the
|
|
374
|
+
* record is deleted) or the session is gone. The expiry does not slide
|
|
375
|
+
* here; renewSession slides it. Requires dataRefresh to be configured.
|
|
320
376
|
*/
|
|
321
377
|
async refreshSessionData(session) {
|
|
322
378
|
if (!this.dataRefresh)
|
|
@@ -334,23 +390,21 @@ export default class LambderSessionManager {
|
|
|
334
390
|
await this.deleteSession(session);
|
|
335
391
|
return null;
|
|
336
392
|
}
|
|
337
|
-
const
|
|
338
|
-
session.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
await this.store.put(session);
|
|
345
|
-
return session;
|
|
393
|
+
const changes = { data: newData, dataExpiresAt: Math.floor(Date.now() / 1000) + this.dataRefresh.ttlSeconds };
|
|
394
|
+
const result = await this.store.update(session.sessionKeyHash, session.secretHash, changes, { dataVersion: session.dataVersion });
|
|
395
|
+
if (result === "missing")
|
|
396
|
+
return null;
|
|
397
|
+
// "stale": served, not written, and the record keeps the version it
|
|
398
|
+
// was read with (see updateSessionData).
|
|
399
|
+
return { ...session, ...changes, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
|
|
346
400
|
}
|
|
347
401
|
;
|
|
348
402
|
/**
|
|
349
403
|
* Checks a record against the session token presented with it: the
|
|
350
404
|
* partition hash and the bearer secret the cookie carries, plus the
|
|
351
|
-
* structural checks and the expiry. This is the half a route needs
|
|
352
|
-
*
|
|
353
|
-
*
|
|
405
|
+
* structural checks and the expiry. This is the half a route needs; a
|
|
406
|
+
* record lookupSession just found by that token's own hash has already
|
|
407
|
+
* passed it, so the read path does not ask again.
|
|
354
408
|
*/
|
|
355
409
|
async isSessionTokenValid(session, sessionToken) {
|
|
356
410
|
if (!session)
|
|
@@ -378,9 +432,8 @@ export default class LambderSessionManager {
|
|
|
378
432
|
/**
|
|
379
433
|
* Checks a record against the CSRF token the request posted: the other
|
|
380
434
|
* half, asked of an API call and not of a route. Separate methods rather
|
|
381
|
-
* than one with a skip flag, because a boolean at the call site
|
|
382
|
-
*
|
|
383
|
-
* different places for different reasons.
|
|
435
|
+
* than one with a skip flag, because a boolean at the call site does not
|
|
436
|
+
* say which half it turns off.
|
|
384
437
|
*/
|
|
385
438
|
async isSessionCsrfTokenValid(session, csrfToken) {
|
|
386
439
|
if (!session?.csrfTokenHash)
|
|
@@ -389,61 +442,133 @@ export default class LambderSessionManager {
|
|
|
389
442
|
return false;
|
|
390
443
|
return this.crypto.constantTimeEqual(session.csrfTokenHash, await this.hashToken(csrfToken));
|
|
391
444
|
}
|
|
445
|
+
/** Deletes the session; false when there was none left to delete. */
|
|
392
446
|
async deleteSession(session) {
|
|
393
|
-
await this.store.delete(session.sessionKeyHash, session.secretHash);
|
|
394
|
-
return true;
|
|
447
|
+
return (await this.store.delete(session.sessionKeyHash, session.secretHash)) !== null;
|
|
395
448
|
}
|
|
396
449
|
;
|
|
397
|
-
/**
|
|
450
|
+
/**
|
|
451
|
+
* Deletes every session that shares the record's subject: "log this
|
|
452
|
+
* subject out everywhere". False when a rotation racing it may have left
|
|
453
|
+
* a session behind (see deleteAllUnder).
|
|
454
|
+
*/
|
|
398
455
|
async deleteSessionAll(session) {
|
|
399
|
-
await this.deleteAllUnder(session.sessionKeyHash);
|
|
400
|
-
return true;
|
|
456
|
+
return await this.deleteAllUnder(session.sessionKeyHash);
|
|
401
457
|
}
|
|
402
458
|
;
|
|
403
459
|
/**
|
|
404
460
|
* Deletes every session created for the given sessionKey (e.g. a user
|
|
405
461
|
* id): "log this subject out everywhere", without needing a fetched
|
|
406
|
-
* session record.
|
|
462
|
+
* session record. False when a rotation racing it may have left a
|
|
463
|
+
* session behind (see deleteAllUnder).
|
|
407
464
|
*/
|
|
408
465
|
async deleteSessionAllByKey(sessionKey) {
|
|
409
|
-
await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
|
|
410
|
-
return true;
|
|
466
|
+
return await this.deleteAllUnder(await this.sessionKeyHashOf(sessionKey));
|
|
411
467
|
}
|
|
412
468
|
;
|
|
469
|
+
/**
|
|
470
|
+
* Deletes every session of a subject, and answers whether it is sure
|
|
471
|
+
* none is left. A pass that finds a record it listed already gone lists
|
|
472
|
+
* again: a rotation writes its new record before deleting the old one
|
|
473
|
+
* (see regenerateSession), so the old one vanishing between this listing
|
|
474
|
+
* and this delete means a new record may have appeared after the
|
|
475
|
+
* listing. When the last pass the bound allows still finds one gone,
|
|
476
|
+
* such a record may be standing, so the call is logged and answers
|
|
477
|
+
* false: a caller ending a subject's sessions after a password change
|
|
478
|
+
* can run it again.
|
|
479
|
+
*/
|
|
413
480
|
async deleteAllUnder(sessionKeyHash) {
|
|
414
|
-
for (
|
|
415
|
-
|
|
481
|
+
for (let pass = 0; pass < SUBJECT_DELETE_MAX_PASSES; pass++) {
|
|
482
|
+
let foundGone = false;
|
|
483
|
+
await this.forEachSessionOf(sessionKeyHash, async (secretHash) => {
|
|
484
|
+
if (await this.store.delete(sessionKeyHash, secretHash) === null)
|
|
485
|
+
foundGone = true;
|
|
486
|
+
});
|
|
487
|
+
if (!foundGone)
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
// The count only, never the subject: a log line is no place for who it was.
|
|
491
|
+
console.error(`Lambder session: deleting every session of a subject found a listed session already gone on each of its ${SUBJECT_DELETE_MAX_PASSES} passes, so a rotation racing it may have left a session behind. It answered false; run it again to be sure.`);
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* One write per session of a subject, a bounded number at a time: a
|
|
496
|
+
* subject with hundreds of sessions is not hundreds of round trips in a
|
|
497
|
+
* row, and not hundreds at once either.
|
|
498
|
+
*/
|
|
499
|
+
async forEachSessionOf(sessionKeyHash, write) {
|
|
500
|
+
const secretHashes = await this.store.listSecretHashes(sessionKeyHash);
|
|
501
|
+
for (let start = 0; start < secretHashes.length; start += SUBJECT_WRITE_CONCURRENCY) {
|
|
502
|
+
await Promise.all(secretHashes.slice(start, start + SUBJECT_WRITE_CONCURRENCY).map(write));
|
|
416
503
|
}
|
|
417
504
|
}
|
|
418
505
|
/**
|
|
419
506
|
* Marks the data of every session of the given sessionKey stale, so each
|
|
420
507
|
* renews via dataRefresh on its next read: "this subject's roles or
|
|
421
|
-
* permissions changed, apply
|
|
422
|
-
* (deleteSessionAllByKey) and without waiting for the data TTL.
|
|
508
|
+
* permissions changed, apply that right away", without logging them out
|
|
509
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Writes
|
|
423
510
|
* dataExpiresAt only, on records that still exist, so it neither
|
|
424
511
|
* resurrects a session deleted in between nor overwrites a concurrent
|
|
425
|
-
* write.
|
|
512
|
+
* write. The write moves each record's dataVersion, even when the
|
|
513
|
+
* deadline already reads this second, so a refresh or data write in
|
|
514
|
+
* flight, conditioned on the version it read, answers "stale" rather
|
|
515
|
+
* than landing data derived before the change. Requires dataRefresh to
|
|
516
|
+
* be configured.
|
|
426
517
|
*/
|
|
427
518
|
async expireSessionDataAllByKey(sessionKey) {
|
|
428
519
|
if (!this.dataRefresh)
|
|
429
520
|
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
430
521
|
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
431
522
|
const now = Math.floor(Date.now() / 1000);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
523
|
+
// An update, so a session deleted in between stays deleted.
|
|
524
|
+
await this.forEachSessionOf(sessionKeyHash, (secretHash) => this.store.update(sessionKeyHash, secretHash, { dataExpiresAt: now }));
|
|
435
525
|
return true;
|
|
436
526
|
}
|
|
437
527
|
;
|
|
528
|
+
/**
|
|
529
|
+
* Replaces the session with a new one under new tokens, carrying over
|
|
530
|
+
* the data as the delete removed it rather than as this request read
|
|
531
|
+
* it, so data another request wrote meanwhile stays. Returns null, and
|
|
532
|
+
* leaves no new session behind, when the record was already gone or
|
|
533
|
+
* over: a logout, "log out everywhere" or a password change that landed
|
|
534
|
+
* during this request stays in force.
|
|
535
|
+
*
|
|
536
|
+
* The new record is written before the old one is deleted. The other
|
|
537
|
+
* way round, a subject-wide delete that listed the subject's sessions
|
|
538
|
+
* between the two would find neither, and the new session would outlive
|
|
539
|
+
* the password change it was racing. Written first, the new record is in
|
|
540
|
+
* that listing, or the old one still is: then either the subject-wide
|
|
541
|
+
* delete removes the old one before this delete does, and this delete
|
|
542
|
+
* takes the new one back out, or this delete removes it first, and the
|
|
543
|
+
* subject-wide delete, finding it gone, lists again (deleteAllUnder).
|
|
544
|
+
*
|
|
545
|
+
* With dataRefresh, the new session's data is due at once: a revocation
|
|
546
|
+
* marked by expireSessionDataAllByKey while the new record was being
|
|
547
|
+
* written may have passed it by, and due, its next read renews the data
|
|
548
|
+
* from the source of truth either way.
|
|
549
|
+
*/
|
|
438
550
|
async regenerateSession(session) {
|
|
439
551
|
if (!session)
|
|
440
552
|
throw new Error("Invalid session");
|
|
441
|
-
|
|
442
|
-
await this.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
553
|
+
const now = Math.floor(Date.now() / 1000);
|
|
554
|
+
const created = await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, this.dataRefresh ? { dataExpiresAt: now } : undefined);
|
|
555
|
+
const replacement = created.session;
|
|
556
|
+
const removed = await this.store.delete(session.sessionKeyHash, session.secretHash);
|
|
557
|
+
if (!removed || removed.expiresAt <= now) {
|
|
558
|
+
await this.store.delete(replacement.sessionKeyHash, replacement.secretHash);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
if (canonicalJson(removed.data) === canonicalJson(session.data))
|
|
562
|
+
return created;
|
|
563
|
+
// Another request wrote the data after this one read it. The write
|
|
564
|
+
// moves the replacement's dataVersion one past the one it was created
|
|
565
|
+
// with, and the record handed back says so: a data write later in
|
|
566
|
+
// this request names the version the store holds rather than answer
|
|
567
|
+
// stale. A mark landing in between still makes it stale, the safe
|
|
568
|
+
// side (see updateSessionData).
|
|
569
|
+
if (await this.store.update(replacement.sessionKeyHash, replacement.secretHash, { data: removed.data }) === "missing")
|
|
570
|
+
return null;
|
|
571
|
+
return { ...created, session: { ...replacement, data: removed.data, dataVersion: replacement.dataVersion + 1 } };
|
|
447
572
|
}
|
|
448
573
|
}
|
|
449
574
|
;
|