lambder 4.5.1 → 4.6.1
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 +15 -1
- package/dist/client/LambderCaller.js +3 -0
- package/dist/core/LambderContext.d.ts +9 -0
- package/dist/core/LambderContext.js +15 -5
- package/dist/core/LambderCookie.d.ts +44 -0
- package/dist/core/LambderCookie.js +28 -0
- package/dist/core/LambderResponseBuilder.d.ts +14 -0
- package/dist/core/LambderResponseBuilder.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/session/LambderSessionController.d.ts +22 -13
- package/dist/session/LambderSessionController.js +44 -37
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
|
|
4
4
|
|
|
5
|
+
**New in 4.6:**
|
|
6
|
+
|
|
7
|
+
- **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
|
|
8
|
+
- **Session cookie scope changes heal**: a cookie's identity is (name, domain, path), so changing the session's `cookie.domain` or `path` on a live deployment leaves the old copy in every browser beside the new one, and a whole-header parse silently picks whichever the browser lists first. The controller now tries every copy of the session cookie (record and CSRF pairing checked per copy), logs the ambiguity, and evicts the stale host-only twin from the response, so a migrated browser recovers on its first request instead of answering `sessionExpired` until the old cookie expires.
|
|
9
|
+
|
|
5
10
|
**New in 4.5:**
|
|
6
11
|
|
|
7
12
|
- **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
|
|
@@ -335,6 +340,12 @@ const lambder = initLambder<SessionData>().create({
|
|
|
335
340
|
|
|
336
341
|
See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
|
|
337
342
|
|
|
343
|
+
#### Cookie scope
|
|
344
|
+
|
|
345
|
+
`session.cookie` sets the scope of the two session cookies: `{ domain: ".example.com" }` shares a login across subdomains, and `domain` may be a `(hostname) => string | undefined` function when one deployment serves several apex domains (return undefined for a host-only cookie); `path`, `sameSite` (default `Lax`) and `secure` (default true) complete it. `LambderCaller` takes the same `sessionCookieDomain` so it can clear the CSRF cookie where the server set it.
|
|
346
|
+
|
|
347
|
+
Changing `domain` or `path` on a live deployment is a migration, because a browser identifies a cookie by (name, domain, path): the old copy stays beside the new one, both arrive on every request, and the browser's order says nothing about which is current. The controller handles the overlap: when the session cookie name arrives more than once it tries every copy (record lookup and CSRF pairing per copy), takes the live one, logs the ambiguity, and evicts the stale host-only twin from the response when a domain is configured. The reverse move, from a domain cookie back to host-only, cannot be evicted (this host cannot name the parent domain), so that copy is tolerated on every request until its own expiry. Renaming the cookies (`tokenCookieKey`, `csrfCookieKey`) alongside the scope change avoids the overlap entirely.
|
|
348
|
+
|
|
338
349
|
#### How the secrets are stored
|
|
339
350
|
|
|
340
351
|
The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
|
|
@@ -504,7 +515,8 @@ The `ctx` object provides access to request data:
|
|
|
504
515
|
| `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
|
|
505
516
|
| `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
|
|
506
517
|
| `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
|
|
507
|
-
| `cookie` | Cookies | `{ rememberMe: "true" }` |
|
|
518
|
+
| `cookie` | Cookies (the first value when a name arrived more than once) | `{ rememberMe: "true" }` |
|
|
519
|
+
| `cookieList` | Every value per cookie name, in header order (a name held at several scopes arrives several times) | `{ rememberMe: ["true"] }` |
|
|
508
520
|
| `headers` | Request headers | `{ "Content-Type": "..." }` |
|
|
509
521
|
| `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
|
|
510
522
|
| `lambdaContext` | AWS Lambda Context | - |
|
|
@@ -517,6 +529,8 @@ The `ctx` object provides access to request data:
|
|
|
517
529
|
**Header Manipulation** (call before returning response):
|
|
518
530
|
- `res.addHeader(key, value)` - Adds a header value (can be called multiple times for same key)
|
|
519
531
|
- `res.setHeader(key, value)` - Sets a header (replaces existing values)
|
|
532
|
+
- `res.setCookie(name, value, options?)` - Adds a Set-Cookie header. Options: `domain` (a string, or a `(hostname) => string | undefined` function resolved against the request host), `path` (default `/`), `sameSite` (default `Lax`), `secure` (default true), `httpOnly`, `maxAge` (seconds), `expires` (Date), `encode` (default encodeURIComponent, which `ctx.cookie` reverses)
|
|
533
|
+
- `res.clearCookie(name, options?)` - Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and `path` it was set with: a cookie's identity is (name, domain, path), so a deletion under another scope deletes nothing
|
|
520
534
|
- `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
|
|
521
535
|
|
|
522
536
|
**Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
|
|
@@ -101,6 +101,9 @@ export default class LambderCaller {
|
|
|
101
101
|
const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
|
|
102
102
|
for (const key of [this.sessionTokenCookieKey, this.sessionCsrfCookieKey]) {
|
|
103
103
|
// Host-only and domain-scoped cookies are distinct entries; clear both.
|
|
104
|
+
// Only the CSRF cookie is reachable from here: the token cookie is
|
|
105
|
+
// HttpOnly, so its removal is the server's (a Set-Cookie on the
|
|
106
|
+
// session-expired or logout response).
|
|
104
107
|
Cookies.remove(key);
|
|
105
108
|
if (resolvedDomain)
|
|
106
109
|
Cookies.remove(key, { domain: resolvedDomain, path: "/" });
|
|
@@ -11,7 +11,16 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
11
11
|
method: string;
|
|
12
12
|
get: Record<string, string | undefined>;
|
|
13
13
|
post: Record<string, any>;
|
|
14
|
+
/** Cookies by name (the first value when a name arrived more than once; see cookieList). */
|
|
14
15
|
cookie: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Every value the request carried per cookie name, in header order. A
|
|
18
|
+
* name normally maps to one value; several arrive when the browser holds
|
|
19
|
+
* that name at more than one scope (host-only beside Domain=, or two
|
|
20
|
+
* paths), typically after a cookie's Domain or Path was changed. The
|
|
21
|
+
* browser's order says nothing about which copy is current.
|
|
22
|
+
*/
|
|
23
|
+
cookieList: Record<string, string[]>;
|
|
15
24
|
session: null;
|
|
16
25
|
apiName: string | null;
|
|
17
26
|
apiPayload: TApiPayload;
|
|
@@ -10,7 +10,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
10
10
|
let path;
|
|
11
11
|
let method;
|
|
12
12
|
let get;
|
|
13
|
-
let
|
|
13
|
+
let cookiePairs;
|
|
14
14
|
let sourceIp;
|
|
15
15
|
const headers = event.headers ?? {};
|
|
16
16
|
if (isV2HttpEvent(event)) {
|
|
@@ -26,7 +26,8 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
26
26
|
for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
|
|
27
27
|
get[key] = value;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// v2 delivers the Cookie header pre-split into name=value pairs.
|
|
30
|
+
cookiePairs = event.cookies ?? [];
|
|
30
31
|
sourceIp = event.requestContext.http.sourceIp || "";
|
|
31
32
|
}
|
|
32
33
|
else {
|
|
@@ -34,10 +35,19 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
34
35
|
path = event.path;
|
|
35
36
|
method = event.httpMethod;
|
|
36
37
|
get = event.queryStringParameters || {};
|
|
37
|
-
|
|
38
|
+
cookiePairs = (headers.Cookie || headers.cookie || "").split(";");
|
|
38
39
|
sourceIp = event.requestContext?.identity?.sourceIp || "";
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
// Parsed pair by pair so a name that arrived more than once keeps every
|
|
42
|
+
// value; a whole-header parse keeps only the first.
|
|
43
|
+
const cookieList = {};
|
|
44
|
+
for (const pair of cookiePairs) {
|
|
45
|
+
for (const [name, value] of Object.entries(cookieParser.parse(pair))) {
|
|
46
|
+
if (value !== undefined)
|
|
47
|
+
(cookieList[name] ??= []).push(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const cookie = Object.fromEntries(Object.entries(cookieList).map(([name, values]) => [name, values[0]]));
|
|
41
51
|
const lowercasedHeaders = {};
|
|
42
52
|
for (const [key, value] of Object.entries(headers)) {
|
|
43
53
|
if (value !== undefined)
|
|
@@ -74,7 +84,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
74
84
|
const requestVersion = isApiCall ? (post.version ?? null) : null;
|
|
75
85
|
return {
|
|
76
86
|
host, path, pathParams: {}, method,
|
|
77
|
-
get, post, cookie, event,
|
|
87
|
+
get, post, cookie, cookieList, event,
|
|
78
88
|
session: null,
|
|
79
89
|
apiName, apiPayload,
|
|
80
90
|
guardData: {},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cookie's Domain attribute: a fixed value such as ".example.com", or a
|
|
3
|
+
* function of the request hostname for one deployment serving several apex
|
|
4
|
+
* domains. Return undefined (or null) for a host-only cookie.
|
|
5
|
+
*/
|
|
6
|
+
export type LambderCookieDomain = string | ((hostname: string) => string | undefined | null);
|
|
7
|
+
/**
|
|
8
|
+
* Attributes of a Set-Cookie header. A cookie's identity in the browser is
|
|
9
|
+
* (name, domain, path): a write under a different domain or path creates a
|
|
10
|
+
* second cookie beside the first instead of replacing it, and a deletion
|
|
11
|
+
* only reaches the cookie whose domain and path it names.
|
|
12
|
+
*/
|
|
13
|
+
export type LambderCookieOptions = {
|
|
14
|
+
domain?: LambderCookieDomain;
|
|
15
|
+
/** Default "/". */
|
|
16
|
+
path?: string;
|
|
17
|
+
/** Default "Lax". */
|
|
18
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
19
|
+
/** Default true. */
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
/** Default false. */
|
|
22
|
+
httpOnly?: boolean;
|
|
23
|
+
/** Lifetime in seconds (Max-Age). A cookie with neither maxAge nor expires lasts the browser session. */
|
|
24
|
+
maxAge?: number;
|
|
25
|
+
/** Absolute expiry (Expires). */
|
|
26
|
+
expires?: Date;
|
|
27
|
+
/** Value encoder. Default encodeURIComponent, which ctx.cookie reverses on the way back in. */
|
|
28
|
+
encode?: (value: string) => string;
|
|
29
|
+
};
|
|
30
|
+
/** Options of a deleting Set-Cookie: only the scope matters. */
|
|
31
|
+
export type LambderClearCookieOptions = Omit<LambderCookieOptions, "maxAge" | "expires" | "encode">;
|
|
32
|
+
/** The Domain attribute for this request, or undefined for a host-only cookie. */
|
|
33
|
+
export declare const resolveCookieDomain: (domain: LambderCookieDomain | undefined, host?: string) => string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* One Set-Cookie header value. `host` resolves a function-form domain; a
|
|
36
|
+
* string domain needs none.
|
|
37
|
+
*/
|
|
38
|
+
export declare const serializeCookie: (name: string, value: string, options?: LambderCookieOptions, host?: string) => string;
|
|
39
|
+
/**
|
|
40
|
+
* A Set-Cookie header value that deletes the cookie. Domain and path must
|
|
41
|
+
* match the cookie being deleted: a mismatch targets a different cookie and
|
|
42
|
+
* deletes nothing.
|
|
43
|
+
*/
|
|
44
|
+
export declare const serializeClearCookie: (name: string, options?: LambderClearCookieOptions, host?: string) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import cookieParser from "cookie";
|
|
2
|
+
/** The Domain attribute for this request, or undefined for a host-only cookie. */
|
|
3
|
+
export const resolveCookieDomain = (domain, host = "") => {
|
|
4
|
+
// Host header can carry a port; browsers match the Domain attribute on hostname only.
|
|
5
|
+
const hostname = host.split(":")[0] ?? "";
|
|
6
|
+
const resolved = typeof domain === "function" ? domain(hostname) : domain;
|
|
7
|
+
return resolved || undefined;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* One Set-Cookie header value. `host` resolves a function-form domain; a
|
|
11
|
+
* string domain needs none.
|
|
12
|
+
*/
|
|
13
|
+
export const serializeCookie = (name, value, options = {}, host) => cookieParser.serialize(name, value, {
|
|
14
|
+
domain: resolveCookieDomain(options.domain, host),
|
|
15
|
+
path: options.path ?? "/",
|
|
16
|
+
sameSite: (options.sameSite ?? "Lax").toLowerCase(),
|
|
17
|
+
secure: options.secure ?? true,
|
|
18
|
+
httpOnly: options.httpOnly ?? false,
|
|
19
|
+
maxAge: options.maxAge,
|
|
20
|
+
expires: options.expires,
|
|
21
|
+
encode: options.encode,
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* A Set-Cookie header value that deletes the cookie. Domain and path must
|
|
25
|
+
* match the cookie being deleted: a mismatch targets a different cookie and
|
|
26
|
+
* deletes nothing.
|
|
27
|
+
*/
|
|
28
|
+
export const serializeClearCookie = (name, options = {}, host) => serializeCookie(name, "", { ...options, maxAge: 0, expires: new Date(0) }, host);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
import { type LambderCookieOptions, type LambderClearCookieOptions } from "./LambderCookie.js";
|
|
2
3
|
import type { LambderFiles } from "./LambderFiles.js";
|
|
3
4
|
import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
|
|
4
5
|
import { LambderSafeHtml } from "../shared/LambderHtml.js";
|
|
@@ -38,6 +39,19 @@ export default class LambderResponseBuilder<TResponse = any> {
|
|
|
38
39
|
private requireFiles;
|
|
39
40
|
addHeader(key: string, value: string): void;
|
|
40
41
|
setHeader(key: string, value: string | string[]): void;
|
|
42
|
+
/**
|
|
43
|
+
* Adds a Set-Cookie header. A function-form `domain` is resolved against
|
|
44
|
+
* the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
|
|
45
|
+
* HttpOnly, browser-session lifetime.
|
|
46
|
+
*/
|
|
47
|
+
setCookie(name: string, value: string, options?: LambderCookieOptions): void;
|
|
48
|
+
/**
|
|
49
|
+
* Adds a Set-Cookie header that deletes the cookie. Pass the same
|
|
50
|
+
* `domain` and `path` the cookie was set with: a cookie's identity is
|
|
51
|
+
* (name, domain, path), and a deletion under a different scope targets a
|
|
52
|
+
* different cookie and deletes nothing.
|
|
53
|
+
*/
|
|
54
|
+
clearCookie(name: string, options?: LambderClearCookieOptions): void;
|
|
41
55
|
logToApiResponse(input: any): void;
|
|
42
56
|
raw(init: LambderRawResponseInit): LambderResponse;
|
|
43
57
|
json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serializeCookie, serializeClearCookie } from "./LambderCookie.js";
|
|
1
2
|
import { LambderResponse } from "./LambderResponse.js";
|
|
2
3
|
export default class LambderResponseBuilder {
|
|
3
4
|
files;
|
|
@@ -45,6 +46,29 @@ export default class LambderResponseBuilder {
|
|
|
45
46
|
this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
|
|
46
47
|
}
|
|
47
48
|
;
|
|
49
|
+
/**
|
|
50
|
+
* Adds a Set-Cookie header. A function-form `domain` is resolved against
|
|
51
|
+
* the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
|
|
52
|
+
* HttpOnly, browser-session lifetime.
|
|
53
|
+
*/
|
|
54
|
+
setCookie(name, value, options) {
|
|
55
|
+
if (!this.ctx)
|
|
56
|
+
throw new Error(".setCookie function is not available within this hook");
|
|
57
|
+
this.addHeader("Set-Cookie", serializeCookie(name, value, options, this.ctx.host));
|
|
58
|
+
}
|
|
59
|
+
;
|
|
60
|
+
/**
|
|
61
|
+
* Adds a Set-Cookie header that deletes the cookie. Pass the same
|
|
62
|
+
* `domain` and `path` the cookie was set with: a cookie's identity is
|
|
63
|
+
* (name, domain, path), and a deletion under a different scope targets a
|
|
64
|
+
* different cookie and deletes nothing.
|
|
65
|
+
*/
|
|
66
|
+
clearCookie(name, options) {
|
|
67
|
+
if (!this.ctx)
|
|
68
|
+
throw new Error(".clearCookie function is not available within this hook");
|
|
69
|
+
this.addHeader("Set-Cookie", serializeClearCookie(name, options, this.ctx.host));
|
|
70
|
+
}
|
|
71
|
+
;
|
|
48
72
|
logToApiResponse(input) {
|
|
49
73
|
if (!this.ctx)
|
|
50
74
|
throw new Error(".logToApiResponse function is not available within this hook");
|
package/dist/index.d.ts
CHANGED
|
@@ -41,3 +41,5 @@ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, Lambd
|
|
|
41
41
|
export { type ApiContractShape, type LambderApiResponse, type LambderApiResponseConfig, } from "./shared/LambderApiContract.js";
|
|
42
42
|
export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./core/LambderContext.js";
|
|
43
43
|
export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
|
|
44
|
+
export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
|
|
45
|
+
export type { LambderCookieOptions, LambderClearCookieOptions, LambderCookieDomain } from "./core/LambderCookie.js";
|
package/dist/index.js
CHANGED
|
@@ -32,3 +32,5 @@ export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
|
|
|
32
32
|
// Typed translations (standalone, isomorphic)
|
|
33
33
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
34
34
|
export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
|
|
35
|
+
// Cookies (res.setCookie / res.clearCookie build on these; exported for code holding a LambderResponse)
|
|
36
|
+
export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
|
|
2
|
+
import { type LambderCookieOptions } from "../core/LambderCookie.js";
|
|
2
3
|
import type LambderSessionManager from "./LambderSessionManager.js";
|
|
3
4
|
import { type LambderSessionContext } from "./LambderSessionManager.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Scope of the session cookies. `domain` is e.g. ".example.com" to share
|
|
7
|
+
* sessions across subdomains, or a function of the request hostname when
|
|
8
|
+
* one deployment serves several apex domains (return undefined for a
|
|
9
|
+
* host-only cookie). Changing `domain` or `path` on a live deployment is a
|
|
10
|
+
* migration: browsers keep the cookie under the old scope beside the new
|
|
11
|
+
* one, and both arrive on every request. fetchSession tolerates that by
|
|
12
|
+
* trying every copy and evicting the stale host-only twin; a copy at a
|
|
13
|
+
* parent domain this host cannot name outlives its own Expires.
|
|
14
|
+
*/
|
|
15
|
+
export type LambderSessionCookieOptions = Pick<LambderCookieOptions, "domain" | "path" | "sameSite" | "secure">;
|
|
15
16
|
export default class LambderSessionController<TSessionData = any> {
|
|
16
17
|
lambderSessionManager: LambderSessionManager;
|
|
17
18
|
sessionTokenCookieKey: string;
|
|
@@ -25,16 +26,24 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
25
26
|
cookieOptions?: LambderSessionCookieOptions;
|
|
26
27
|
ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
|
|
27
28
|
});
|
|
28
|
-
|
|
29
|
+
/** The configured scope with the domain resolved for this request, or the host-only scope. */
|
|
30
|
+
private cookieScope;
|
|
29
31
|
/** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
|
|
30
32
|
private setSessionCookies;
|
|
31
33
|
private clearSessionCookies;
|
|
34
|
+
/**
|
|
35
|
+
* Every well-formed value the request carried under the session cookie
|
|
36
|
+
* name. More than one means the browser holds the cookie at several
|
|
37
|
+
* scopes, and the order says nothing about which copy is current.
|
|
38
|
+
*/
|
|
39
|
+
private sessionTokenCandidates;
|
|
32
40
|
private areRequestSessionTokensValid;
|
|
33
41
|
createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
|
|
34
42
|
regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
|
|
35
43
|
fetchSession(): Promise<LambderSessionContext<TSessionData>>;
|
|
36
44
|
fetchSessionIfExists(): Promise<LambderSessionContext<TSessionData> | null>;
|
|
37
|
-
|
|
45
|
+
/** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
|
|
46
|
+
isSessionValid(session: any, sessionToken?: string | undefined): boolean;
|
|
38
47
|
updateSessionData(newData: any): Promise<LambderSessionContext>;
|
|
39
48
|
/**
|
|
40
49
|
* Force-runs the dataRefresh callback now (see the session option of create) and
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../core/LambderCookie.js";
|
|
1
2
|
import { LambderSessionDataRefreshError, LambderSessionReadError } from "./LambderSessionManager.js";
|
|
3
|
+
/** The tokens are hex, so the cookie carries them as they are (the format existing browsers hold). */
|
|
4
|
+
const rawValue = (value) => value;
|
|
2
5
|
export default class LambderSessionController {
|
|
3
6
|
lambderSessionManager;
|
|
4
7
|
sessionTokenCookieKey;
|
|
@@ -13,39 +16,37 @@ export default class LambderSessionController {
|
|
|
13
16
|
this.ctx = ctx;
|
|
14
17
|
}
|
|
15
18
|
;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const resolvedDomain = typeof domain === "function" ? domain(hostname) : domain;
|
|
21
|
-
const parts = [
|
|
22
|
-
`${key}=${value}`,
|
|
23
|
-
`Expires=${new Date(expiresAtMs).toUTCString()}`,
|
|
24
|
-
`Path=${path}`,
|
|
25
|
-
...(resolvedDomain ? [`Domain=${resolvedDomain}`] : []),
|
|
26
|
-
...(httpOnly ? ["HttpOnly"] : []),
|
|
27
|
-
`SameSite=${sameSite}`,
|
|
28
|
-
...(secure ? ["Secure"] : []),
|
|
29
|
-
];
|
|
30
|
-
return parts.join("; ");
|
|
19
|
+
/** The configured scope with the domain resolved for this request, or the host-only scope. */
|
|
20
|
+
cookieScope(hostOnly = false) {
|
|
21
|
+
const { domain, path, sameSite, secure } = this.cookieOptions;
|
|
22
|
+
return { domain: hostOnly ? undefined : resolveCookieDomain(domain, this.ctx.host), path, sameSite, secure };
|
|
31
23
|
}
|
|
32
24
|
;
|
|
33
25
|
/** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
|
|
34
26
|
setSessionCookies(created) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
27
|
+
const scope = this.cookieScope();
|
|
28
|
+
const expires = new Date(created.session.expiresAt * 1000);
|
|
29
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionTokenCookieKey, created.sessionToken, { ...scope, expires, httpOnly: true, encode: rawValue }) });
|
|
30
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionCsrfCookieKey, created.csrfToken, { ...scope, expires, encode: rawValue }) });
|
|
38
31
|
}
|
|
39
32
|
;
|
|
40
|
-
clearSessionCookies() {
|
|
41
|
-
const
|
|
42
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
43
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
33
|
+
clearSessionCookies(hostOnly = false) {
|
|
34
|
+
const scope = this.cookieScope(hostOnly);
|
|
35
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionTokenCookieKey, { ...scope, httpOnly: true }) });
|
|
36
|
+
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionCsrfCookieKey, scope) });
|
|
37
|
+
}
|
|
38
|
+
;
|
|
39
|
+
/**
|
|
40
|
+
* Every well-formed value the request carried under the session cookie
|
|
41
|
+
* name. More than one means the browser holds the cookie at several
|
|
42
|
+
* scopes, and the order says nothing about which copy is current.
|
|
43
|
+
*/
|
|
44
|
+
sessionTokenCandidates() {
|
|
45
|
+
return (this.ctx.cookieList?.[this.sessionTokenCookieKey] ?? []).filter((token) => token.split(":").length === 2);
|
|
44
46
|
}
|
|
45
47
|
;
|
|
46
48
|
areRequestSessionTokensValid() {
|
|
47
|
-
const
|
|
48
|
-
const isSessionTokenValid = !!sessionToken && sessionToken.split(":").length === 2;
|
|
49
|
+
const isSessionTokenValid = this.sessionTokenCandidates().length > 0;
|
|
49
50
|
if (this.ctx._otherInternal.isApiCall) {
|
|
50
51
|
const csrfToken = this.ctx.post?.token;
|
|
51
52
|
const isCsrfTokenValid = typeof csrfToken === "string" && csrfToken.length > 0;
|
|
@@ -76,16 +77,23 @@ export default class LambderSessionController {
|
|
|
76
77
|
if (!this.areRequestSessionTokensValid()) {
|
|
77
78
|
throw new Error("Session tokens are invalid");
|
|
78
79
|
}
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
80
|
+
const candidates = this.sessionTokenCandidates();
|
|
81
|
+
if (candidates.length > 1) {
|
|
82
|
+
console.warn(`Lambder session: ${candidates.length} "${this.sessionTokenCookieKey}" cookies arrived from ${this.ctx.host}; the browser holds the cookie at several scopes and a stale copy may shadow the live one. Trying each.`);
|
|
83
|
+
}
|
|
84
|
+
for (const sessionToken of candidates) {
|
|
85
|
+
const session = await this.lambderSessionManager.getSession(sessionToken);
|
|
86
|
+
if (!session || !this.isSessionValid(session, sessionToken))
|
|
87
|
+
continue;
|
|
88
|
+
// The other copies are stale. This response can evict the
|
|
89
|
+
// host-only twin of a Domain= cookie; a copy at a parent domain
|
|
90
|
+
// this host cannot name is out of reach and expires on its own.
|
|
91
|
+
if (candidates.length > 1 && this.cookieScope().domain)
|
|
92
|
+
this.clearSessionCookies(true);
|
|
93
|
+
this.ctx.session = session;
|
|
94
|
+
return session;
|
|
95
|
+
}
|
|
96
|
+
throw new Error("Session not found");
|
|
89
97
|
}
|
|
90
98
|
;
|
|
91
99
|
async fetchSessionIfExists() {
|
|
@@ -104,14 +112,13 @@ export default class LambderSessionController {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
;
|
|
107
|
-
|
|
115
|
+
/** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
|
|
116
|
+
isSessionValid(session, sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey]) {
|
|
108
117
|
if (this.ctx._otherInternal.isApiCall) {
|
|
109
|
-
const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
|
|
110
118
|
const csrfToken = this.ctx.post?.token;
|
|
111
119
|
return this.lambderSessionManager.isSessionValid(session, sessionToken, csrfToken);
|
|
112
120
|
}
|
|
113
121
|
else {
|
|
114
|
-
const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
|
|
115
122
|
return this.lambderSessionManager.isSessionValid(session, sessionToken, null, true);
|
|
116
123
|
}
|
|
117
124
|
}
|